diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 80291c73e61..87fed908c6e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,6 @@ name: Report an issue with Home Assistant Core description: Report an issue with Home Assistant Core. +type: Bug body: - type: markdown attributes: diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ce89d8c2b10..bd45753d010 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -32,7 +32,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -116,7 +116,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -175,7 +175,7 @@ jobs: sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt - name: Download translations - uses: actions/download-artifact@v4.2.1 + uses: actions/download-artifact@v4.3.0 with: name: translations @@ -324,7 +324,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Cosign - uses: sigstore/cosign-installer@v3.8.1 + uses: sigstore/cosign-installer@v3.8.2 with: cosign-release: "v2.2.3" @@ -457,12 +457,12 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Download translations - uses: actions/download-artifact@v4.2.1 + uses: actions/download-artifact@v4.3.0 with: name: translations @@ -509,7 +509,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.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@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -531,7 +531,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3 + uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c46ec3cda54..e10bc607258 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 12 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 9 - HA_SHORT_VERSION: "2025.4" + HA_SHORT_VERSION: "2025.5" DEFAULT_PYTHON: "3.13" ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version @@ -249,7 +249,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -294,7 +294,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -334,7 +334,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -374,7 +374,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -484,7 +484,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -587,7 +587,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -620,7 +620,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -653,7 +653,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Dependency review - uses: actions/dependency-review-action@v4.5.0 + uses: actions/dependency-review-action@v4.6.0 with: license-check: false # We use our own license audit checks @@ -677,7 +677,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -720,7 +720,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -767,7 +767,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -812,7 +812,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -889,7 +889,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -949,7 +949,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -968,7 +968,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets - uses: actions/download-artifact@v4.2.1 + uses: actions/download-artifact@v4.3.0 with: name: pytest_buckets - name: Compile English translations @@ -1074,7 +1074,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1208,7 +1208,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1312,12 +1312,12 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.2.1 + uses: actions/download-artifact@v4.3.0 with: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.4.0 + uses: codecov/codecov-action@v5.4.2 with: fail_ci_if_error: true flags: full-suite @@ -1359,7 +1359,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1454,12 +1454,12 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.2.1 + uses: actions/download-artifact@v4.3.0 with: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.4.0 + uses: codecov/codecov-action@v5.4.2 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} @@ -1479,7 +1479,7 @@ jobs: timeout-minutes: 10 steps: - name: Download all coverage artifacts - uses: actions/download-artifact@v4.2.1 + uses: actions/download-artifact@v4.3.0 with: pattern: test-results-* - name: Upload test results to Codecov diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index bd072752d16..c6181121043 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.13 + uses: github/codeql-action/init@v3.28.16 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.13 + uses: github/codeql-action/analyze@v3.28.16 with: category: "/language:python" diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 0b6abe8fe2c..8a668d548d3 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index d27a62bab80..ea02b249dc9 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -36,7 +36,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -138,17 +138,17 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.2.1 + uses: actions/download-artifact@v4.3.0 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.2.1 + uses: actions/download-artifact@v4.3.0 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.2.1 + uses: actions/download-artifact@v4.3.0 with: name: requirements_diff @@ -187,22 +187,22 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.2.1 + uses: actions/download-artifact@v4.3.0 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.2.1 + uses: actions/download-artifact@v4.3.0 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.2.1 + uses: actions/download-artifact@v4.3.0 with: name: requirements_diff - name: Download requirements_all_wheels - uses: actions/download-artifact@v4.2.1 + uses: actions/download-artifact@v4.3.0 with: name: requirements_all_wheels diff --git a/.strict-typing b/.strict-typing index e0c4e569f4b..9752ae30fff 100644 --- a/.strict-typing +++ b/.strict-typing @@ -291,6 +291,7 @@ homeassistant.components.kaleidescape.* homeassistant.components.knocki.* homeassistant.components.knx.* homeassistant.components.kraken.* +homeassistant.components.kulersky.* homeassistant.components.lacrosse.* homeassistant.components.lacrosse_view.* homeassistant.components.lamarzocco.* @@ -362,8 +363,10 @@ homeassistant.components.no_ip.* homeassistant.components.nordpool.* homeassistant.components.notify.* homeassistant.components.notion.* +homeassistant.components.ntfy.* homeassistant.components.number.* homeassistant.components.nut.* +homeassistant.components.ohme.* homeassistant.components.onboarding.* homeassistant.components.oncue.* homeassistant.components.onedrive.* @@ -383,6 +386,7 @@ homeassistant.components.pandora.* homeassistant.components.panel_custom.* homeassistant.components.peblar.* homeassistant.components.peco.* +homeassistant.components.pegel_online.* homeassistant.components.persistent_notification.* homeassistant.components.person.* homeassistant.components.pi_hole.* @@ -459,6 +463,7 @@ homeassistant.components.slack.* homeassistant.components.sleepiq.* homeassistant.components.smhi.* homeassistant.components.smlight.* +homeassistant.components.smtp.* homeassistant.components.snooz.* homeassistant.components.solarlog.* homeassistant.components.sonarr.* diff --git a/CODEOWNERS b/CODEOWNERS index 8afd3bab028..8fb77243bd1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -171,6 +171,8 @@ build.json @home-assistant/supervisor /homeassistant/components/avea/ @pattyland /homeassistant/components/awair/ @ahayworth @danielsjf /tests/components/awair/ @ahayworth @danielsjf +/homeassistant/components/aws_s3/ @tomasbedrich +/tests/components/aws_s3/ @tomasbedrich /homeassistant/components/axis/ @Kane610 /tests/components/axis/ @Kane610 /homeassistant/components/azure_data_explorer/ @kaareseras @@ -432,7 +434,7 @@ build.json @home-assistant/supervisor /homeassistant/components/entur_public_transport/ @hfurubotten /homeassistant/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie -/homeassistant/components/ephember/ @ttroy50 +/homeassistant/components/ephember/ @ttroy50 @roberty99 /homeassistant/components/epic_games_store/ @hacf-fr @Quentame /tests/components/epic_games_store/ @hacf-fr @Quentame /homeassistant/components/epion/ @lhgravendeel @@ -704,6 +706,8 @@ build.json @home-assistant/supervisor /tests/components/image_upload/ @home-assistant/core /homeassistant/components/imap/ @jbouwh /tests/components/imap/ @jbouwh +/homeassistant/components/imeon_inverter/ @Imeon-Energy +/tests/components/imeon_inverter/ @Imeon-Energy /homeassistant/components/imgw_pib/ @bieniu /tests/components/imgw_pib/ @bieniu /homeassistant/components/improv_ble/ @emontnemery @@ -935,6 +939,8 @@ build.json @home-assistant/supervisor /tests/components/metoffice/ @MrHarcombe @avee87 /homeassistant/components/microbees/ @microBeesTech /tests/components/microbees/ @microBeesTech +/homeassistant/components/miele/ @astrandb +/tests/components/miele/ @astrandb /homeassistant/components/mikrotik/ @engrbm87 /tests/components/mikrotik/ @engrbm87 /homeassistant/components/mill/ @danielhiversen @@ -1047,6 +1053,8 @@ build.json @home-assistant/supervisor /tests/components/nsw_fuel_station/ @nickw444 /homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte /tests/components/nsw_rural_fire_service_feed/ @exxamalte +/homeassistant/components/ntfy/ @tr4nt0r +/tests/components/ntfy/ @tr4nt0r /homeassistant/components/nuheat/ @tstabrawa /tests/components/nuheat/ @tstabrawa /homeassistant/components/nuki/ @pschmitt @pvizeli @pree @@ -1075,8 +1083,6 @@ build.json @home-assistant/supervisor /homeassistant/components/ombi/ @larssont /homeassistant/components/onboarding/ @home-assistant/core /tests/components/onboarding/ @home-assistant/core -/homeassistant/components/oncue/ @bdraco @peterager -/tests/components/oncue/ @bdraco @peterager /homeassistant/components/ondilo_ico/ @JeromeHXP /tests/components/ondilo_ico/ @JeromeHXP /homeassistant/components/onedrive/ @zweckj @@ -1254,6 +1260,8 @@ build.json @home-assistant/supervisor /tests/components/recovery_mode/ @home-assistant/core /homeassistant/components/refoss/ @ashionky /tests/components/refoss/ @ashionky +/homeassistant/components/rehlko/ @bdraco @peterager +/tests/components/rehlko/ @bdraco @peterager /homeassistant/components/remote/ @home-assistant/core /tests/components/remote/ @home-assistant/core /homeassistant/components/remote_calendar/ @Thomas55555 @@ -1387,7 +1395,6 @@ build.json @home-assistant/supervisor /homeassistant/components/siren/ @home-assistant/core @raman325 /tests/components/siren/ @home-assistant/core @raman325 /homeassistant/components/sisyphus/ @jkeljo -/homeassistant/components/sky_hub/ @rogerselwyn /homeassistant/components/sky_remote/ @dunnmj @saty9 /tests/components/sky_remote/ @dunnmj @saty9 /homeassistant/components/skybell/ @tkdrob @@ -1434,8 +1441,8 @@ build.json @home-assistant/supervisor /tests/components/solarlog/ @Ernst79 @dontinelli /homeassistant/components/solax/ @squishykid @Darsstar /tests/components/solax/ @squishykid @Darsstar -/homeassistant/components/soma/ @ratsept @sebfortier2288 -/tests/components/soma/ @ratsept @sebfortier2288 +/homeassistant/components/soma/ @ratsept +/tests/components/soma/ @ratsept /homeassistant/components/sonarr/ @ctalkington /tests/components/sonarr/ @ctalkington /homeassistant/components/songpal/ @rytilahti @shenxn @@ -1467,7 +1474,8 @@ build.json @home-assistant/supervisor /tests/components/steam_online/ @tkdrob /homeassistant/components/steamist/ @bdraco /tests/components/steamist/ @bdraco -/homeassistant/components/stiebel_eltron/ @fucm +/homeassistant/components/stiebel_eltron/ @fucm @ThyMYthOS +/tests/components/stiebel_eltron/ @fucm @ThyMYthOS /homeassistant/components/stookwijzer/ @fwestenberg /tests/components/stookwijzer/ @fwestenberg /homeassistant/components/stream/ @hunterjm @uvjustin @allenporter @@ -1670,8 +1678,8 @@ build.json @home-assistant/supervisor /tests/components/vlc_telnet/ @rodripf @MartinHjelmare /homeassistant/components/vodafone_station/ @paoloantinori @chemelli74 /tests/components/vodafone_station/ @paoloantinori @chemelli74 -/homeassistant/components/voip/ @balloob @synesthesiam -/tests/components/voip/ @balloob @synesthesiam +/homeassistant/components/voip/ @balloob @synesthesiam @jaminh +/tests/components/voip/ @balloob @synesthesiam @jaminh /homeassistant/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund /homeassistant/components/volvooncall/ @molobrakos diff --git a/Dockerfile b/Dockerfile index 0a74e0a3aac..549837ddef0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,7 @@ RUN \ && go2rtc --version # Install uv -RUN pip3 install uv==0.6.10 +RUN pip3 install uv==0.7.1 WORKDIR /usr/src diff --git a/build.yaml b/build.yaml index 87dad1bf5ef..00df4196523 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.02.1 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.02.1 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.02.1 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.02.1 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.02.1 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.05.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.05.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.05.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.05.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.05.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 02a3b8c8fcc..f88912478a7 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -53,6 +53,7 @@ from .components import ( logbook as logbook_pre_import, # noqa: F401 lovelace as lovelace_pre_import, # noqa: F401 onboarding as onboarding_pre_import, # noqa: F401 + person as person_pre_import, # noqa: F401 recorder as recorder_import, # noqa: F401 - not named pre_import since it has requirements repairs as repairs_pre_import, # noqa: F401 search as search_pre_import, # noqa: F401 @@ -859,8 +860,14 @@ async def _async_set_up_integrations( integrations, all_integrations = await _async_resolve_domains_and_preload( hass, config ) - all_domains = set(all_integrations) - domains = set(integrations) + # Detect all cycles + integrations_after_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, all_integrations.values(), set(all_integrations) + ) + ) + all_domains = set(integrations_after_dependencies) + domains = set(integrations) & all_domains _LOGGER.info( "Domains to be set up: %s | %s", @@ -868,6 +875,8 @@ async def _async_set_up_integrations( all_domains - domains, ) + async_set_domains_to_be_loaded(hass, all_domains) + # Initialize recorder if "recorder" in all_domains: recorder.async_initialize_recorder(hass) @@ -900,24 +909,12 @@ async def _async_set_up_integrations( stage_dep_domains_unfiltered = { dep for domain in stage_domains - for dep in all_integrations[domain].all_dependencies + for dep in integrations_after_dependencies[domain] if dep not in stage_domains } stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components stage_all_domains = stage_domains | stage_dep_domains - stage_all_integrations = { - domain: all_integrations[domain] for domain in stage_all_domains - } - # Detect all cycles - stage_integrations_after_dependencies = ( - await loader.resolve_integrations_after_dependencies( - hass, stage_all_integrations.values(), stage_all_domains - ) - ) - stage_all_domains = set(stage_integrations_after_dependencies) - stage_domains &= stage_all_domains - stage_dep_domains &= stage_all_domains _LOGGER.info( "Setting up stage %s: %s | %s\nDependencies: %s | %s", @@ -928,8 +925,6 @@ async def _async_set_up_integrations( stage_dep_domains_unfiltered - stage_dep_domains, ) - async_set_domains_to_be_loaded(hass, stage_all_domains) - if timeout is None: await _async_setup_multi_components(hass, stage_all_domains, config) continue diff --git a/homeassistant/brands/amazon.json b/homeassistant/brands/amazon.json index a7caea2b932..624a8a17b7d 100644 --- a/homeassistant/brands/amazon.json +++ b/homeassistant/brands/amazon.json @@ -1,5 +1,12 @@ { "domain": "amazon", "name": "Amazon", - "integrations": ["alexa", "amazon_polly", "aws", "fire_tv", "route53"] + "integrations": [ + "alexa", + "amazon_polly", + "aws", + "aws_s3", + "fire_tv", + "route53" + ] } diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index 872cfc0aac5..2da0e2426f5 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -6,6 +6,7 @@ "google_assistant_sdk", "google_cloud", "google_drive", + "google_gemini", "google_generative_ai_conversation", "google_mail", "google_maps", diff --git a/homeassistant/brands/nuki.json b/homeassistant/brands/nuki.json new file mode 100644 index 00000000000..f5fe075889b --- /dev/null +++ b/homeassistant/brands/nuki.json @@ -0,0 +1,6 @@ +{ + "domain": "nuki", + "name": "Nuki", + "integrations": ["nuki"], + "iot_standards": ["matter"] +} diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 7216f5a0b9b..e1dc4a9abcb 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -67,6 +67,7 @@ POLLEN_CATEGORY_MAP = { 2: "moderate", 3: "high", 4: "very_high", + 5: "extreme", } UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40) UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6) diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index e1a71c5e1a5..19e52be1ce3 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -72,10 +72,11 @@ "level": { "name": "Level", "state": { - "high": "High", - "low": "Low", + "extreme": "Extreme", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "Moderate", - "very_high": "Very high" + "very_high": "[%key:common::state::very_high%]" } } } @@ -89,10 +90,11 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } @@ -123,10 +125,11 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } @@ -167,10 +170,11 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } @@ -181,10 +185,11 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } @@ -195,10 +200,11 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } diff --git a/homeassistant/components/adax/__init__.py b/homeassistant/components/adax/__init__.py index d4fe13ee4f6..d7c1097d54b 100644 --- a/homeassistant/components/adax/__init__.py +++ b/homeassistant/components/adax/__init__.py @@ -2,25 +2,38 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from .const import CONNECTION_TYPE, LOCAL +from .coordinator import AdaxCloudCoordinator, AdaxConfigEntry, AdaxLocalCoordinator + PLATFORMS = [Platform.CLIMATE] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: AdaxConfigEntry) -> bool: """Set up Adax from a config entry.""" + if entry.data.get(CONNECTION_TYPE) == LOCAL: + local_coordinator = AdaxLocalCoordinator(hass, entry) + entry.runtime_data = local_coordinator + else: + cloud_coordinator = AdaxCloudCoordinator(hass, entry) + entry.runtime_data = cloud_coordinator + + await entry.runtime_data.async_config_entry_first_refresh() + 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: AdaxConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: AdaxConfigEntry +) -> bool: """Migrate old entry.""" # convert title and unique_id to string if config_entry.version == 1: diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py index 078640cd367..b41a4432437 100644 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -12,57 +12,42 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, - CONF_IP_ADDRESS, - CONF_PASSWORD, - CONF_TOKEN, CONF_UNIQUE_ID, PRECISION_WHOLE, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ACCOUNT_ID, CONNECTION_TYPE, DOMAIN, LOCAL +from . import AdaxConfigEntry +from .const import CONNECTION_TYPE, DOMAIN, LOCAL +from .coordinator import AdaxCloudCoordinator, AdaxLocalCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: AdaxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Adax thermostat with config flow.""" if entry.data.get(CONNECTION_TYPE) == LOCAL: - adax_data_handler = AdaxLocal( - entry.data[CONF_IP_ADDRESS], - entry.data[CONF_TOKEN], - websession=async_get_clientsession(hass, verify_ssl=False), - ) + local_coordinator = cast(AdaxLocalCoordinator, entry.runtime_data) async_add_entities( - [LocalAdaxDevice(adax_data_handler, entry.data[CONF_UNIQUE_ID])], True + [LocalAdaxDevice(local_coordinator, entry.data[CONF_UNIQUE_ID])], + ) + else: + cloud_coordinator = cast(AdaxCloudCoordinator, entry.runtime_data) + async_add_entities( + AdaxDevice(cloud_coordinator, device_id) + for device_id in cloud_coordinator.data ) - return - - adax_data_handler = Adax( - entry.data[ACCOUNT_ID], - entry.data[CONF_PASSWORD], - websession=async_get_clientsession(hass), - ) - - async_add_entities( - ( - AdaxDevice(room, adax_data_handler) - for room in await adax_data_handler.get_rooms() - ), - True, - ) -class AdaxDevice(ClimateEntity): +class AdaxDevice(CoordinatorEntity[AdaxCloudCoordinator], ClimateEntity): """Representation of a heater.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] @@ -76,20 +61,37 @@ class AdaxDevice(ClimateEntity): _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, heater_data: dict[str, Any], adax_data_handler: Adax) -> None: + def __init__( + self, + coordinator: AdaxCloudCoordinator, + device_id: str, + ) -> None: """Initialize the heater.""" - self._device_id = heater_data["id"] - self._adax_data_handler = adax_data_handler + super().__init__(coordinator) + self._adax_data_handler: Adax = coordinator.adax_data_handler + self._device_id = device_id - self._attr_unique_id = f"{heater_data['homeId']}_{heater_data['id']}" + self._attr_name = self.room["name"] + self._attr_unique_id = f"{self.room['homeId']}_{self._device_id}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, heater_data["id"])}, + identifiers={(DOMAIN, device_id)}, # Instead of setting the device name to the entity name, adax # should be updated to set has_entity_name = True, and set the entity # name to None name=cast(str | None, self.name), manufacturer="Adax", ) + self._apply_data(self.room) + + @property + def available(self) -> bool: + """Whether the entity is available or not.""" + return super().available and self._device_id in self.coordinator.data + + @property + def room(self) -> dict[str, Any]: + """Gets the data for this particular device.""" + return self.coordinator.data[self._device_id] async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" @@ -104,7 +106,9 @@ class AdaxDevice(ClimateEntity): ) else: return - await self._adax_data_handler.update() + + # Request data refresh from source to verify that update was successful + await self.coordinator.async_request_refresh() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -114,28 +118,31 @@ class AdaxDevice(ClimateEntity): self._device_id, temperature, True ) - async def async_update(self) -> None: - """Get the latest data.""" - for room in await self._adax_data_handler.get_rooms(): - if room["id"] != self._device_id: - continue - self._attr_name = room["name"] - self._attr_current_temperature = room.get("temperature") - self._attr_target_temperature = room.get("targetTemperature") - if room["heatingEnabled"]: - self._attr_hvac_mode = HVACMode.HEAT - self._attr_icon = "mdi:radiator" - else: - self._attr_hvac_mode = HVACMode.OFF - self._attr_icon = "mdi:radiator-off" - return + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if room := self.room: + self._apply_data(room) + super()._handle_coordinator_update() + + def _apply_data(self, room: dict[str, Any]) -> None: + """Update the appropriate attributues based on received data.""" + self._attr_current_temperature = room.get("temperature") + self._attr_target_temperature = room.get("targetTemperature") + if room["heatingEnabled"]: + self._attr_hvac_mode = HVACMode.HEAT + self._attr_icon = "mdi:radiator" + else: + self._attr_hvac_mode = HVACMode.OFF + self._attr_icon = "mdi:radiator-off" -class LocalAdaxDevice(ClimateEntity): +class LocalAdaxDevice(CoordinatorEntity[AdaxLocalCoordinator], ClimateEntity): """Representation of a heater.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] - _attr_hvac_mode = HVACMode.HEAT + _attr_hvac_mode = HVACMode.OFF + _attr_icon = "mdi:radiator-off" _attr_max_temp = 35 _attr_min_temp = 5 _attr_supported_features = ( @@ -146,9 +153,10 @@ class LocalAdaxDevice(ClimateEntity): _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, adax_data_handler: AdaxLocal, unique_id: str) -> None: + def __init__(self, coordinator: AdaxLocalCoordinator, unique_id: str) -> None: """Initialize the heater.""" - self._adax_data_handler = adax_data_handler + super().__init__(coordinator) + self._adax_data_handler: AdaxLocal = coordinator.adax_data_handler self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, @@ -169,17 +177,20 @@ class LocalAdaxDevice(ClimateEntity): return await self._adax_data_handler.set_target_temperature(temperature) - async def async_update(self) -> None: - """Get the latest data.""" - data = await self._adax_data_handler.get_status() - self._attr_current_temperature = data["current_temperature"] - self._attr_available = self._attr_current_temperature is not None - if (target_temp := data["target_temperature"]) == 0: - self._attr_hvac_mode = HVACMode.OFF - self._attr_icon = "mdi:radiator-off" - if target_temp == 0: - self._attr_target_temperature = self._attr_min_temp - else: - self._attr_hvac_mode = HVACMode.HEAT - self._attr_icon = "mdi:radiator" - self._attr_target_temperature = target_temp + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if data := self.coordinator.data: + self._attr_current_temperature = data["current_temperature"] + self._attr_available = self._attr_current_temperature is not None + if (target_temp := data["target_temperature"]) == 0: + self._attr_hvac_mode = HVACMode.OFF + self._attr_icon = "mdi:radiator-off" + if target_temp == 0: + self._attr_target_temperature = self._attr_min_temp + else: + self._attr_hvac_mode = HVACMode.HEAT + self._attr_icon = "mdi:radiator" + self._attr_target_temperature = target_temp + + super()._handle_coordinator_update() diff --git a/homeassistant/components/adax/const.py b/homeassistant/components/adax/const.py index 306dd52e657..3461df8aa63 100644 --- a/homeassistant/components/adax/const.py +++ b/homeassistant/components/adax/const.py @@ -1,5 +1,6 @@ """Constants for the Adax integration.""" +import datetime from typing import Final ACCOUNT_ID: Final = "account_id" @@ -9,3 +10,5 @@ DOMAIN: Final = "adax" LOCAL = "Local" WIFI_SSID = "wifi_ssid" WIFI_PSWD = "wifi_pswd" + +SCAN_INTERVAL = datetime.timedelta(seconds=60) diff --git a/homeassistant/components/adax/coordinator.py b/homeassistant/components/adax/coordinator.py new file mode 100644 index 00000000000..d3dd819bea4 --- /dev/null +++ b/homeassistant/components/adax/coordinator.py @@ -0,0 +1,71 @@ +"""DataUpdateCoordinator for the Adax component.""" + +import logging +from typing import Any, cast + +from adax import Adax +from adax_local import Adax as AdaxLocal + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_TOKEN +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 ACCOUNT_ID, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +type AdaxConfigEntry = ConfigEntry[AdaxCloudCoordinator | AdaxLocalCoordinator] + + +class AdaxCloudCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Coordinator for updating data to and from Adax (cloud).""" + + def __init__(self, hass: HomeAssistant, entry: AdaxConfigEntry) -> None: + """Initialize the Adax coordinator used for Cloud mode.""" + super().__init__( + hass, + config_entry=entry, + logger=_LOGGER, + name="AdaxCloud", + update_interval=SCAN_INTERVAL, + ) + + self.adax_data_handler = Adax( + entry.data[ACCOUNT_ID], + entry.data[CONF_PASSWORD], + websession=async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + """Fetch data from the Adax.""" + rooms = await self.adax_data_handler.get_rooms() or [] + return {r["id"]: r for r in rooms} + + +class AdaxLocalCoordinator(DataUpdateCoordinator[dict[str, Any] | None]): + """Coordinator for updating data to and from Adax (local).""" + + def __init__(self, hass: HomeAssistant, entry: AdaxConfigEntry) -> None: + """Initialize the Adax coordinator used for Local mode.""" + super().__init__( + hass, + config_entry=entry, + logger=_LOGGER, + name="AdaxLocal", + update_interval=SCAN_INTERVAL, + ) + + self.adax_data_handler = AdaxLocal( + entry.data[CONF_IP_ADDRESS], + entry.data[CONF_TOKEN], + websession=async_get_clientsession(hass, verify_ssl=False), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from the Adax.""" + if result := await self.adax_data_handler.get_status(): + return cast(dict[str, Any], result) + raise UpdateFailed("Got invalid status from device") diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index 2d9b6be529d..cef4db57358 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -68,8 +68,8 @@ "led_bar_mode": { "name": "LED bar mode", "state": { - "off": "Off", - "co2": "Carbon dioxide", + "off": "[%key:common::state::off%]", + "co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", "pm": "Particulate matter" } }, @@ -143,8 +143,8 @@ "led_bar_mode": { "name": "[%key:component::airgradient::entity::select::led_bar_mode::name%]", "state": { - "off": "[%key:component::airgradient::entity::select::led_bar_mode::state::off%]", - "co2": "[%key:component::airgradient::entity::select::led_bar_mode::state::co2%]", + "off": "[%key:common::state::off%]", + "co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", "pm": "[%key:component::airgradient::entity::select::led_bar_mode::state::pm%]" } }, diff --git a/homeassistant/components/airvisual/strings.json b/homeassistant/components/airvisual/strings.json index 7a5f8b1d5c7..9d53be4dee7 100644 --- a/homeassistant/components/airvisual/strings.json +++ b/homeassistant/components/airvisual/strings.json @@ -16,8 +16,8 @@ "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "city": "City", - "country": "Country", - "state": "State" + "state": "State", + "country": "[%key:common::config_flow::data::country%]" } }, "reauth_confirm": { @@ -56,12 +56,12 @@ "sensor": { "pollutant_label": { "state": { - "co": "Carbon monoxide", - "n2": "Nitrogen dioxide", - "o3": "Ozone", - "p1": "PM10", - "p2": "PM2.5", - "s2": "Sulfur dioxide" + "co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "n2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "o3": "[%key:component::sensor::entity_component::ozone::name%]", + "p1": "[%key:component::sensor::entity_component::pm10::name%]", + "p2": "[%key:component::sensor::entity_component::pm25::name%]", + "s2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" } }, "pollutant_level": { diff --git a/homeassistant/components/airzone/sensor.py b/homeassistant/components/airzone/sensor.py index f76eb1466a3..66657836b74 100644 --- a/homeassistant/components/airzone/sensor.py +++ b/homeassistant/components/airzone/sensor.py @@ -9,6 +9,8 @@ from aioairzone.const import ( AZD_HUMIDITY, AZD_TEMP, AZD_TEMP_UNIT, + AZD_THERMOSTAT_BATTERY, + AZD_THERMOSTAT_SIGNAL, AZD_WEBSERVER, AZD_WIFI_RSSI, AZD_ZONES, @@ -73,6 +75,20 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), + SensorEntityDescription( + device_class=SensorDeviceClass.BATTERY, + key=AZD_THERMOSTAT_BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + key=AZD_THERMOSTAT_SIGNAL, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + translation_key="thermostat_signal", + ), ) diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json index cd313b821aa..c7d9701aa83 100644 --- a/homeassistant/components/airzone/strings.json +++ b/homeassistant/components/airzone/strings.json @@ -76,6 +76,9 @@ "sensor": { "rssi": { "name": "RSSI" + }, + "thermostat_signal": { + "name": "Signal strength" } } } diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 3b6f94df57c..ecc9634f36a 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.11"] + "requirements": ["aioairzone-cloud==0.6.12"] } diff --git a/homeassistant/components/airzone_cloud/strings.json b/homeassistant/components/airzone_cloud/strings.json index 6e0f9adcd66..5481bfbc984 100644 --- a/homeassistant/components/airzone_cloud/strings.json +++ b/homeassistant/components/airzone_cloud/strings.json @@ -32,9 +32,9 @@ "air_quality": { "name": "Air Quality mode", "state": { - "off": "Off", - "on": "On", - "auto": "Auto" + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "auto": "[%key:common::state::auto%]" } }, "modes": { diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 6a0b1830b7e..7088b624e21 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -719,7 +719,7 @@ class LockCapabilities(AlexaEntity): yield Alexa(self.entity) -@ENTITY_ADAPTERS.register(media_player.const.DOMAIN) +@ENTITY_ADAPTERS.register(media_player.DOMAIN) class MediaPlayerCapabilities(AlexaEntity): """Class to represent MediaPlayer capabilities.""" @@ -757,9 +757,7 @@ class MediaPlayerCapabilities(AlexaEntity): if supported & media_player.MediaPlayerEntityFeature.SELECT_SOURCE: inputs = AlexaInputController.get_valid_inputs( - self.entity.attributes.get( - media_player.const.ATTR_INPUT_SOURCE_LIST, [] - ) + self.entity.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST, []) ) if len(inputs) > 0: yield AlexaInputController(self.entity) @@ -776,8 +774,7 @@ class MediaPlayerCapabilities(AlexaEntity): and domain != "denonavr" ): inputs = AlexaEqualizerController.get_valid_inputs( - self.entity.attributes.get(media_player.const.ATTR_SOUND_MODE_LIST) - or [] + self.entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST) or [] ) if len(inputs) > 0: yield AlexaEqualizerController(self.entity) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 8bd393e2d11..747cbd85adb 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -566,7 +566,7 @@ async def async_api_set_volume( data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, - media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume, + media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, } await hass.services.async_call( @@ -589,7 +589,7 @@ async def async_api_select_input( # Attempt to map the ALL UPPERCASE payload name to a source. # Strips trailing 1 to match single input devices. - source_list = entity.attributes.get(media_player.const.ATTR_INPUT_SOURCE_LIST) or [] + source_list = entity.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST) or [] for source in source_list: formatted_source = ( source.lower().replace("-", "").replace("_", "").replace(" ", "") @@ -611,7 +611,7 @@ async def async_api_select_input( data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, - media_player.const.ATTR_INPUT_SOURCE: media_input, + media_player.ATTR_INPUT_SOURCE: media_input, } await hass.services.async_call( @@ -636,7 +636,7 @@ async def async_api_adjust_volume( volume_delta = int(directive.payload["volume"]) entity = directive.entity - current_level = entity.attributes[media_player.const.ATTR_MEDIA_VOLUME_LEVEL] + current_level = entity.attributes[media_player.ATTR_MEDIA_VOLUME_LEVEL] # read current state try: @@ -648,7 +648,7 @@ async def async_api_adjust_volume( data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, - media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume, + media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, } await hass.services.async_call( @@ -709,7 +709,7 @@ async def async_api_set_mute( entity = directive.entity data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, - media_player.const.ATTR_MEDIA_VOLUME_MUTED: mute, + media_player.ATTR_MEDIA_VOLUME_MUTED: mute, } await hass.services.async_call( @@ -1708,15 +1708,13 @@ async def async_api_changechannel( data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, - media_player.const.ATTR_MEDIA_CONTENT_ID: channel, - media_player.const.ATTR_MEDIA_CONTENT_TYPE: ( - media_player.const.MEDIA_TYPE_CHANNEL - ), + media_player.ATTR_MEDIA_CONTENT_ID: channel, + media_player.ATTR_MEDIA_CONTENT_TYPE: (media_player.MediaType.CHANNEL), } await hass.services.async_call( entity.domain, - media_player.const.SERVICE_PLAY_MEDIA, + media_player.SERVICE_PLAY_MEDIA, data, blocking=False, context=context, @@ -1825,13 +1823,13 @@ async def async_api_set_eq_mode( context: ha.Context, ) -> AlexaResponse: """Process a SetMode request for EqualizerController.""" - mode = directive.payload["mode"] + mode: str = directive.payload["mode"] entity = directive.entity data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} - sound_mode_list = entity.attributes.get(media_player.const.ATTR_SOUND_MODE_LIST) + sound_mode_list = entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST) if sound_mode_list and mode.lower() in sound_mode_list: - data[media_player.const.ATTR_SOUND_MODE] = mode.lower() + data[media_player.ATTR_SOUND_MODE] = mode.lower() else: msg = f"failed to map sound mode {mode} to a mode on {entity.entity_id}" raise AlexaInvalidValueError(msg) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 20e3ef1d7c7..e3181ee1405 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -3,10 +3,10 @@ from __future__ import annotations from asyncio import timeout +from collections.abc import Mapping from http import HTTPStatus import json import logging -from types import MappingProxyType from typing import TYPE_CHECKING, Any, cast from uuid import uuid4 @@ -260,10 +260,10 @@ async def async_enable_proactive_mode( def extra_significant_check( hass: HomeAssistant, old_state: str, - old_attrs: dict[Any, Any] | MappingProxyType[Any, Any], + old_attrs: Mapping[Any, Any], old_extra_arg: Any, new_state: str, - new_attrs: dict[str, Any] | MappingProxyType[Any, Any], + new_attrs: Mapping[Any, Any], new_extra_arg: Any, ) -> bool: """Check if the serialized data has changed.""" diff --git a/homeassistant/components/amazon_polly/manifest.json b/homeassistant/components/amazon_polly/manifest.json index e7fbf8edc74..f684292d9a2 100644 --- a/homeassistant/components/amazon_polly/manifest.json +++ b/homeassistant/components/amazon_polly/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["boto3", "botocore", "s3transfer"], "quality_scale": "legacy", - "requirements": ["boto3==1.34.131"] + "requirements": ["boto3==1.37.1"] } diff --git a/homeassistant/components/analytics_insights/strings.json b/homeassistant/components/analytics_insights/strings.json index 10d3c19a2f6..222906efa0b 100644 --- a/homeassistant/components/analytics_insights/strings.json +++ b/homeassistant/components/analytics_insights/strings.json @@ -3,12 +3,12 @@ "step": { "user": { "data": { - "tracked_addons": "Addons", + "tracked_addons": "Add-ons", "tracked_integrations": "Integrations", "tracked_custom_integrations": "Custom integrations" }, "data_description": { - "tracked_addons": "Select the addons you want to track", + "tracked_addons": "Select the add-ons you want to track", "tracked_integrations": "Select the integrations you want to track", "tracked_custom_integrations": "Select the custom integrations you want to track" } diff --git a/homeassistant/components/android_ip_webcam/manifest.json b/homeassistant/components/android_ip_webcam/manifest.json index 57af567ec51..d7a9f8ad97a 100644 --- a/homeassistant/components/android_ip_webcam/manifest.json +++ b/homeassistant/components/android_ip_webcam/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/android_ip_webcam", "iot_class": "local_polling", - "requirements": ["pydroid-ipcam==2.0.0"] + "requirements": ["pydroid-ipcam==3.0.0"] } diff --git a/homeassistant/components/androidtv_remote/entity.py b/homeassistant/components/androidtv_remote/entity.py index 44b2d2a5f20..bf146a11e13 100644 --- a/homeassistant/components/androidtv_remote/entity.py +++ b/homeassistant/components/androidtv_remote/entity.py @@ -73,7 +73,7 @@ class AndroidTVRemoteBaseEntity(Entity): self._api.send_key_command(key_code, direction) except ConnectionClosed as exc: raise HomeAssistantError( - "Connection to Android TV device is closed" + translation_domain=DOMAIN, translation_key="connection_closed" ) from exc def _send_launch_app_command(self, app_link: str) -> None: @@ -85,5 +85,5 @@ class AndroidTVRemoteBaseEntity(Entity): self._api.send_launch_app_command(app_link) except ConnectionClosed as exc: raise HomeAssistantError( - "Connection to Android TV device is closed" + translation_domain=DOMAIN, translation_key="connection_closed" ) from exc diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py index 3d3a97092bc..5bc205b32df 100644 --- a/homeassistant/components/androidtv_remote/media_player.py +++ b/homeassistant/components/androidtv_remote/media_player.py @@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AndroidTVRemoteConfigEntry -from .const import CONF_APP_ICON, CONF_APP_NAME +from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN from .entity import AndroidTVRemoteBaseEntity PARALLEL_UPDATES = 0 @@ -233,5 +233,5 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt await asyncio.sleep(delay_secs) except ConnectionClosed as exc: raise HomeAssistantError( - "Connection to Android TV device is closed" + translation_domain=DOMAIN, translation_key="connection_closed" ) from exc diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index e41cbcf9a76..106cac3a63d 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -54,5 +54,10 @@ } } } + }, + "exceptions": { + "connection_closed": { + "message": "Connection to the Android TV device is closed" + } } } diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index e53a479d7d4..ebad206af61 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from functools import partial import logging from types import MappingProxyType @@ -52,7 +53,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( RECOMMENDED_OPTIONS = { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, } @@ -134,9 +135,8 @@ class AnthropicOptionsFlow(OptionsFlow): if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: - if user_input[CONF_LLM_HASS_API] == "none": - user_input.pop(CONF_LLM_HASS_API) - + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) if user_input.get( CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET ) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS): @@ -151,12 +151,16 @@ class AnthropicOptionsFlow(OptionsFlow): options = { CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], + CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API), } suggested_values = options.copy() if not suggested_values.get(CONF_PROMPT): suggested_values[CONF_PROMPT] = llm.DEFAULT_INSTRUCTIONS_PROMPT + if ( + suggested_llm_apis := suggested_values.get(CONF_LLM_HASS_API) + ) and isinstance(suggested_llm_apis, str): + suggested_values[CONF_LLM_HASS_API] = [suggested_llm_apis] schema = self.add_suggested_values_to_schema( vol.Schema(anthropic_config_option_schema(self.hass, options)), @@ -172,28 +176,22 @@ class AnthropicOptionsFlow(OptionsFlow): def anthropic_config_option_schema( hass: HomeAssistant, - options: dict[str, Any] | MappingProxyType[str, Any], + options: Mapping[str, Any], ) -> dict: """Return a schema for Anthropic completion options.""" hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label="No control", - value="none", - ) - ] - hass_apis.extend( SelectOptionDict( label=api.name, value=api.id, ) for api in llm.async_get_apis(hass) - ) + ] schema = { vol.Optional(CONF_PROMPT): TemplateSelector(), - vol.Optional(CONF_LLM_HASS_API, default="none"): SelectSelector( - SelectSelectorConfig(options=hass_apis) - ), + vol.Optional( + CONF_LLM_HASS_API, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), vol.Required( CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) ): bool, diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 288ec63509e..7e1fda467a8 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -9,11 +9,13 @@ from anthropic import AsyncStream from anthropic._types import NOT_GIVEN from anthropic.types import ( InputJSONDelta, + MessageDeltaUsage, MessageParam, MessageStreamEvent, RawContentBlockDeltaEvent, RawContentBlockStartEvent, RawContentBlockStopEvent, + RawMessageDeltaEvent, RawMessageStartEvent, RawMessageStopEvent, RedactedThinkingBlock, @@ -31,6 +33,7 @@ from anthropic.types import ( ToolResultBlockParam, ToolUseBlock, ToolUseBlockParam, + Usage, ) from voluptuous_openapi import convert @@ -162,7 +165,8 @@ def _convert_content( return messages -async def _transform_stream( +async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place + chat_log: conversation.ChatLog, result: AsyncStream[MessageStreamEvent], messages: list[MessageParam], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: @@ -207,6 +211,7 @@ async def _transform_stream( | None ) = None current_tool_args: str + input_usage: Usage | None = None async for response in result: LOGGER.debug("Received response: %s", response) @@ -215,6 +220,7 @@ async def _transform_stream( if response.message.role != "assistant": raise ValueError("Unexpected message role") current_message = MessageParam(role=response.message.role, content=[]) + input_usage = response.message.usage elif isinstance(response, RawContentBlockStartEvent): if isinstance(response.content_block, ToolUseBlock): current_block = ToolUseBlockParam( @@ -265,32 +271,54 @@ async def _transform_stream( if current_block is None: raise ValueError("Unexpected stop event without a current block") if current_block["type"] == "tool_use": - tool_block = cast(ToolUseBlockParam, current_block) + # tool block tool_args = json.loads(current_tool_args) if current_tool_args else {} - tool_block["input"] = tool_args + current_block["input"] = tool_args yield { "tool_calls": [ llm.ToolInput( - id=tool_block["id"], - tool_name=tool_block["name"], + id=current_block["id"], + tool_name=current_block["name"], tool_args=tool_args, ) ] } elif current_block["type"] == "thinking": - thinking_block = cast(ThinkingBlockParam, current_block) - LOGGER.debug("Thinking: %s", thinking_block["thinking"]) + # thinking block + LOGGER.debug("Thinking: %s", current_block["thinking"]) if current_message is None: raise ValueError("Unexpected stop event without a current message") current_message["content"].append(current_block) # type: ignore[union-attr] current_block = None + elif isinstance(response, RawMessageDeltaEvent): + if (usage := response.usage) is not None: + chat_log.async_trace(_create_token_stats(input_usage, usage)) elif isinstance(response, RawMessageStopEvent): if current_message is not None: messages.append(current_message) current_message = None +def _create_token_stats( + input_usage: Usage | None, response_usage: MessageDeltaUsage +) -> dict[str, Any]: + """Create token stats for conversation agent tracing.""" + input_tokens = 0 + cached_input_tokens = 0 + if input_usage: + input_tokens = input_usage.input_tokens + cached_input_tokens = input_usage.cache_creation_input_tokens or 0 + output_tokens = response_usage.output_tokens + return { + "stats": { + "input_tokens": input_tokens, + "cached_input_tokens": cached_input_tokens, + "output_tokens": output_tokens, + } + } + + class AnthropicConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent ): @@ -393,7 +421,8 @@ class AnthropicConversationEntity( [ content async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, _transform_stream(stream, messages) + user_input.agent_id, + _transform_stream(chat_log, stream, messages), ) if not isinstance(content, conversation.AssistantContent) ] diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py index f3829b41f61..dfeb56c8d06 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -53,10 +53,8 @@ class OnlineStatus(CoordinatorEntity[APCUPSdCoordinator], BinarySensorEntity): """Initialize the APCUPSd binary device.""" super().__init__(coordinator, context=description.key.upper()) - # Set up unique id and device info if serial number is available. - if (serial_no := coordinator.data.serial_no) is not None: - self._attr_unique_id = f"{serial_no}_{description.key}" self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}" self._attr_device_info = coordinator.device_info @property diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py index e2c1af50cee..505543e0936 100644 --- a/homeassistant/components/apcupsd/coordinator.py +++ b/homeassistant/components/apcupsd/coordinator.py @@ -85,11 +85,16 @@ class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]): self._host = host self._port = port + @property + def unique_device_id(self) -> str: + """Return a unique ID of the device, which is the serial number (if available) or the config entry ID.""" + return self.data.serial_no or self.config_entry.entry_id + @property def device_info(self) -> DeviceInfo: """Return the DeviceInfo of this APC UPS, if serial number is available.""" return DeviceInfo( - identifiers={(DOMAIN, self.data.serial_no or self.config_entry.entry_id)}, + identifiers={(DOMAIN, self.unique_device_id)}, model=self.data.model, manufacturer="APC", name=self.data.name or "APC UPS", @@ -108,4 +113,7 @@ class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]): data = await aioapcaccess.request_status(self._host, self._port) return APCUPSdData(data) except (OSError, asyncio.IncompleteReadError) as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from error diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 02016efa4ca..a3faf6b0268 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -458,11 +458,8 @@ class APCUPSdSensor(CoordinatorEntity[APCUPSdCoordinator], SensorEntity): """Initialize the sensor.""" super().__init__(coordinator=coordinator, context=description.key.upper()) - # Set up unique id and device info if serial number is available. - if (serial_no := coordinator.data.serial_no) is not None: - self._attr_unique_id = f"{serial_no}_{description.key}" - self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}" self._attr_device_info = coordinator.device_info # Initial update of attributes. diff --git a/homeassistant/components/apcupsd/strings.json b/homeassistant/components/apcupsd/strings.json index fb5df9ec390..27a620491d1 100644 --- a/homeassistant/components/apcupsd/strings.json +++ b/homeassistant/components/apcupsd/strings.json @@ -93,7 +93,7 @@ "name": "Internal temperature" }, "last_self_test": { - "name": "Last self test" + "name": "Last self-test" }, "last_transfer": { "name": "Last transfer" @@ -177,7 +177,7 @@ "name": "Restore requirement" }, "self_test_result": { - "name": "Self test result" + "name": "Self-test result" }, "sensitivity": { "name": "Sensitivity" @@ -195,7 +195,7 @@ "name": "Status" }, "self_test_interval": { - "name": "Self test interval" + "name": "Self-test interval" }, "time_left": { "name": "Time left" @@ -219,5 +219,10 @@ "name": "Transfer to battery" } } + }, + "exceptions": { + "cannot_connect": { + "message": "Cannot connect to APC UPS Daemon." + } } } diff --git a/homeassistant/components/apsystems/coordinator.py b/homeassistant/components/apsystems/coordinator.py index ca423055176..f7f1039b8a4 100644 --- a/homeassistant/components/apsystems/coordinator.py +++ b/homeassistant/components/apsystems/coordinator.py @@ -43,6 +43,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]): config_entry: ApSystemsConfigEntry device_version: str + battery_system: bool def __init__( self, @@ -68,6 +69,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]): self.api.max_power = device_info.maxPower self.api.min_power = device_info.minPower self.device_version = device_info.devVer + self.battery_system = device_info.isBatterySystem async def _async_update_data(self) -> ApSystemsSensorData: try: diff --git a/homeassistant/components/apsystems/manifest.json b/homeassistant/components/apsystems/manifest.json index a58530b05e2..eb1acb40d17 100644 --- a/homeassistant/components/apsystems/manifest.json +++ b/homeassistant/components/apsystems/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/apsystems", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["apsystems-ez1==2.4.0"] + "loggers": ["APsystemsEZ1"], + "requirements": ["apsystems-ez1==2.6.0"] } diff --git a/homeassistant/components/apsystems/strings.json b/homeassistant/components/apsystems/strings.json index b3a10ca49a7..bdcd464ee9c 100644 --- a/homeassistant/components/apsystems/strings.json +++ b/homeassistant/components/apsystems/strings.json @@ -21,7 +21,7 @@ "entity": { "binary_sensor": { "off_grid_status": { - "name": "Off grid status" + "name": "Off-grid status" }, "dc_1_short_circuit_error_status": { "name": "DC 1 short circuit error status" diff --git a/homeassistant/components/apsystems/switch.py b/homeassistant/components/apsystems/switch.py index e1017f95448..5451f2885fe 100644 --- a/homeassistant/components/apsystems/switch.py +++ b/homeassistant/components/apsystems/switch.py @@ -36,6 +36,8 @@ class ApSystemsInverterSwitch(ApSystemsEntity, SwitchEntity): super().__init__(data) self._api = data.coordinator.api self._attr_unique_id = f"{data.device_id}_inverter_status" + if data.coordinator.battery_system: + self._attr_available = False async def async_update(self) -> None: """Update switch status and availability.""" diff --git a/homeassistant/components/aquacell/strings.json b/homeassistant/components/aquacell/strings.json index 53304d04804..e07adf3c199 100644 --- a/homeassistant/components/aquacell/strings.json +++ b/homeassistant/components/aquacell/strings.json @@ -36,9 +36,9 @@ "wi_fi_strength": { "name": "Wi-Fi strength", "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } } diff --git a/homeassistant/components/aranet/strings.json b/homeassistant/components/aranet/strings.json index f786f4b2d4d..bb2ea3b2887 100644 --- a/homeassistant/components/aranet/strings.json +++ b/homeassistant/components/aranet/strings.json @@ -26,7 +26,7 @@ "sensor": { "threshold": { "state": { - "error": "Error", + "error": "[%key:common::state::error%]", "green": "Green", "yellow": "Yellow", "red": "Red" diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 330c4bcfb67..a34f191b7a7 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -2,10 +2,9 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import datetime, timedelta import logging -from types import MappingProxyType from typing import Any from pyasuswrt import AsusWrtError @@ -363,7 +362,7 @@ class AsusWrtRouter: """Add a function to call when router is closed.""" self._on_close.append(func) - def update_options(self, new_options: MappingProxyType[str, Any]) -> bool: + def update_options(self, new_options: Mapping[str, Any]) -> bool: """Update router options.""" req_reload = False for name, new_opt in new_options.items(): diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 5e16a22af76..a6b2961c2a0 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.7"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==2.6.0"] } diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 856060f8c75..6243c11a791 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -18,6 +18,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, ATTR_NAME, + CONF_ACTIONS, CONF_ALIAS, CONF_CONDITIONS, CONF_DEVICE_ID, @@ -27,6 +28,7 @@ from homeassistant.const import ( CONF_MODE, CONF_PATH, CONF_PLATFORM, + CONF_TRIGGERS, CONF_VARIABLES, CONF_ZONE, EVENT_HOMEASSISTANT_STARTED, @@ -86,11 +88,9 @@ from homeassistant.util.hass_dict import HassKey from .config import AutomationConfig, ValidationStatus from .const import ( - CONF_ACTIONS, CONF_INITIAL_STATE, CONF_TRACE, CONF_TRIGGER_VARIABLES, - CONF_TRIGGERS, DEFAULT_INITIAL_STATE, DOMAIN, LOGGER, diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index fe74865ca92..23ae10eea2b 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -14,11 +14,15 @@ from homeassistant.components import blueprint from homeassistant.components.trace import TRACE_CONFIG_SCHEMA from homeassistant.config import config_per_platform, config_without_domain from homeassistant.const import ( + CONF_ACTION, + CONF_ACTIONS, CONF_ALIAS, CONF_CONDITION, CONF_CONDITIONS, CONF_DESCRIPTION, CONF_ID, + CONF_TRIGGER, + CONF_TRIGGERS, CONF_VARIABLES, ) from homeassistant.core import HomeAssistant @@ -30,14 +34,10 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.yaml.input import UndefinedSubstitution from .const import ( - CONF_ACTION, - CONF_ACTIONS, CONF_HIDE_ENTITY, CONF_INITIAL_STATE, CONF_TRACE, - CONF_TRIGGER, CONF_TRIGGER_VARIABLES, - CONF_TRIGGERS, DOMAIN, LOGGER, ) @@ -58,34 +58,9 @@ _MINIMAL_PLATFORM_SCHEMA = vol.Schema( def _backward_compat_schema(value: Any | None) -> Any: """Backward compatibility for automations.""" - if not isinstance(value, dict): - return value - - # `trigger` has been renamed to `triggers` - if CONF_TRIGGER in value: - if CONF_TRIGGERS in value: - raise vol.Invalid( - "Cannot specify both 'trigger' and 'triggers'. Please use 'triggers' only." - ) - value[CONF_TRIGGERS] = value.pop(CONF_TRIGGER) - - # `condition` has been renamed to `conditions` - if CONF_CONDITION in value: - if CONF_CONDITIONS in value: - raise vol.Invalid( - "Cannot specify both 'condition' and 'conditions'. Please use 'conditions' only." - ) - value[CONF_CONDITIONS] = value.pop(CONF_CONDITION) - - # `action` has been renamed to `actions` - if CONF_ACTION in value: - if CONF_ACTIONS in value: - raise vol.Invalid( - "Cannot specify both 'action' and 'actions'. Please use 'actions' only." - ) - value[CONF_ACTIONS] = value.pop(CONF_ACTION) - - return value + value = cv.renamed(CONF_TRIGGER, CONF_TRIGGERS)(value) + value = cv.renamed(CONF_ACTION, CONF_ACTIONS)(value) + return cv.renamed(CONF_CONDITION, CONF_CONDITIONS)(value) PLATFORM_SCHEMA = vol.All( diff --git a/homeassistant/components/automation/const.py b/homeassistant/components/automation/const.py index c4ac636282e..f9d2fc1b77f 100644 --- a/homeassistant/components/automation/const.py +++ b/homeassistant/components/automation/const.py @@ -2,10 +2,6 @@ import logging -CONF_ACTION = "action" -CONF_ACTIONS = "actions" -CONF_TRIGGER = "trigger" -CONF_TRIGGERS = "triggers" CONF_TRIGGER_VARIABLES = "trigger_variables" DOMAIN = "automation" diff --git a/homeassistant/components/aws/manifest.json b/homeassistant/components/aws/manifest.json index 12149e4388a..92ae37c857b 100644 --- a/homeassistant/components/aws/manifest.json +++ b/homeassistant/components/aws/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["aiobotocore", "botocore"], "quality_scale": "legacy", - "requirements": ["aiobotocore==2.13.1", "botocore==1.34.131"] + "requirements": ["aiobotocore==2.21.1", "botocore==1.37.1"] } diff --git a/homeassistant/components/aws_s3/__init__.py b/homeassistant/components/aws_s3/__init__.py new file mode 100644 index 00000000000..b709595ae4a --- /dev/null +++ b/homeassistant/components/aws_s3/__init__.py @@ -0,0 +1,82 @@ +"""The AWS S3 integration.""" + +from __future__ import annotations + +import logging +from typing import cast + +from aiobotocore.client import AioBaseClient as S3Client +from aiobotocore.session import AioSession +from botocore.exceptions import ClientError, ConnectionError, ParamValidationError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import ( + CONF_ACCESS_KEY_ID, + CONF_BUCKET, + CONF_ENDPOINT_URL, + CONF_SECRET_ACCESS_KEY, + DATA_BACKUP_AGENT_LISTENERS, + DOMAIN, +) + +type S3ConfigEntry = ConfigEntry[S3Client] + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool: + """Set up S3 from a config entry.""" + + data = cast(dict, entry.data) + try: + session = AioSession() + # pylint: disable-next=unnecessary-dunder-call + client = await session.create_client( + "s3", + endpoint_url=data.get(CONF_ENDPOINT_URL), + aws_secret_access_key=data[CONF_SECRET_ACCESS_KEY], + aws_access_key_id=data[CONF_ACCESS_KEY_ID], + ).__aenter__() + await client.head_bucket(Bucket=data[CONF_BUCKET]) + except ClientError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_credentials", + ) from err + except ParamValidationError as err: + if "Invalid bucket name" in str(err): + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_bucket_name", + ) from err + except ValueError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_endpoint_url", + ) from err + except ConnectionError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + + entry.runtime_data = client + + def notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(notify_backup_listeners)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool: + """Unload a config entry.""" + client = entry.runtime_data + await client.__aexit__(None, None, None) + return True diff --git a/homeassistant/components/aws_s3/backup.py b/homeassistant/components/aws_s3/backup.py new file mode 100644 index 00000000000..7ef1289132d --- /dev/null +++ b/homeassistant/components/aws_s3/backup.py @@ -0,0 +1,330 @@ +"""Backup platform for the AWS S3 integration.""" + +from collections.abc import AsyncIterator, Callable, Coroutine +import functools +import json +import logging +from time import time +from typing import Any + +from botocore.exceptions import BotoCoreError + +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, + suggested_filename, +) +from homeassistant.core import HomeAssistant, callback + +from . import S3ConfigEntry +from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +_LOGGER = logging.getLogger(__name__) +CACHE_TTL = 300 + +# S3 part size requirements: 5 MiB to 5 GiB per part +# https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html +# We set the threshold to 20 MiB to avoid too many parts. +# Note that each part is allocated in the memory. +MULTIPART_MIN_PART_SIZE_BYTES = 20 * 2**20 + + +def handle_boto_errors[T]( + func: Callable[..., Coroutine[Any, Any, T]], +) -> Callable[..., Coroutine[Any, Any, T]]: + """Handle BotoCoreError exceptions by converting them to BackupAgentError.""" + + @functools.wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> T: + """Catch BotoCoreError and raise BackupAgentError.""" + try: + return await func(*args, **kwargs) + except BotoCoreError as err: + error_msg = f"Failed during {func.__name__}" + raise BackupAgentError(error_msg) from err + + return wrapper + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries: list[S3ConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) + return [S3BackupAgent(hass, entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed. + + :return: A function to unregister the listener. + """ + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: + """Return the suggested filenames for the backup and metadata files.""" + base_name = suggested_filename(backup).rsplit(".", 1)[0] + return f"{base_name}.tar", f"{base_name}.metadata.json" + + +class S3BackupAgent(BackupAgent): + """Backup agent for the S3 integration.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: S3ConfigEntry) -> None: + """Initialize the S3 agent.""" + super().__init__() + self._client = entry.runtime_data + self._bucket: str = entry.data[CONF_BUCKET] + self.name = entry.title + self.unique_id = entry.entry_id + self._backup_cache: dict[str, AgentBackup] = {} + self._cache_expiration = time() + + @handle_boto_errors + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: An async iterator that yields bytes. + """ + backup = await self._find_backup_by_id(backup_id) + tar_filename, _ = suggested_filenames(backup) + + response = await self._client.get_object(Bucket=self._bucket, Key=tar_filename) + return response["Body"].iter_chunks() + + 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. + """ + tar_filename, metadata_filename = suggested_filenames(backup) + + try: + if backup.size < MULTIPART_MIN_PART_SIZE_BYTES: + await self._upload_simple(tar_filename, open_stream) + else: + await self._upload_multipart(tar_filename, open_stream) + + # Upload the metadata file + metadata_content = json.dumps(backup.as_dict()) + await self._client.put_object( + Bucket=self._bucket, + Key=metadata_filename, + Body=metadata_content, + ) + except BotoCoreError as err: + raise BackupAgentError("Failed to upload backup") from err + else: + # Reset cache after successful upload + self._cache_expiration = time() + + async def _upload_simple( + self, + tar_filename: str, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + ) -> None: + """Upload a small file using simple upload. + + :param tar_filename: The target filename for the backup. + :param open_stream: A function returning an async iterator that yields bytes. + """ + _LOGGER.debug("Starting simple upload for %s", tar_filename) + stream = await open_stream() + file_data = bytearray() + async for chunk in stream: + file_data.extend(chunk) + + await self._client.put_object( + Bucket=self._bucket, + Key=tar_filename, + Body=bytes(file_data), + ) + + async def _upload_multipart( + self, + tar_filename: str, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + ): + """Upload a large file using multipart upload. + + :param tar_filename: The target filename for the backup. + :param open_stream: A function returning an async iterator that yields bytes. + """ + _LOGGER.debug("Starting multipart upload for %s", tar_filename) + multipart_upload = await self._client.create_multipart_upload( + Bucket=self._bucket, + Key=tar_filename, + ) + upload_id = multipart_upload["UploadId"] + try: + parts = [] + part_number = 1 + buffer_size = 0 # bytes + buffer: list[bytes] = [] + + stream = await open_stream() + async for chunk in stream: + buffer_size += len(chunk) + buffer.append(chunk) + + # If buffer size meets minimum part size, upload it as a part + if buffer_size >= MULTIPART_MIN_PART_SIZE_BYTES: + _LOGGER.debug( + "Uploading part number %d, size %d", part_number, buffer_size + ) + part = await self._client.upload_part( + Bucket=self._bucket, + Key=tar_filename, + PartNumber=part_number, + UploadId=upload_id, + Body=b"".join(buffer), + ) + parts.append({"PartNumber": part_number, "ETag": part["ETag"]}) + part_number += 1 + buffer_size = 0 + buffer = [] + + # Upload the final buffer as the last part (no minimum size requirement) + if buffer: + _LOGGER.debug( + "Uploading final part number %d, size %d", part_number, buffer_size + ) + part = await self._client.upload_part( + Bucket=self._bucket, + Key=tar_filename, + PartNumber=part_number, + UploadId=upload_id, + Body=b"".join(buffer), + ) + parts.append({"PartNumber": part_number, "ETag": part["ETag"]}) + + await self._client.complete_multipart_upload( + Bucket=self._bucket, + Key=tar_filename, + UploadId=upload_id, + MultipartUpload={"Parts": parts}, + ) + + except BotoCoreError: + try: + await self._client.abort_multipart_upload( + Bucket=self._bucket, + Key=tar_filename, + UploadId=upload_id, + ) + except BotoCoreError: + _LOGGER.exception("Failed to abort multipart upload") + raise + + @handle_boto_errors + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + """ + backup = await self._find_backup_by_id(backup_id) + tar_filename, metadata_filename = suggested_filenames(backup) + + # Delete both the backup file and its metadata file + await self._client.delete_object(Bucket=self._bucket, Key=tar_filename) + await self._client.delete_object(Bucket=self._bucket, Key=metadata_filename) + + # Reset cache after successful deletion + self._cache_expiration = time() + + @handle_boto_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + backups = await self._list_backups() + return list(backups.values()) + + @handle_boto_errors + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup: + """Return a backup.""" + return await self._find_backup_by_id(backup_id) + + async def _find_backup_by_id(self, backup_id: str) -> AgentBackup: + """Find a backup by its backup ID.""" + backups = await self._list_backups() + if backup := backups.get(backup_id): + return backup + + raise BackupNotFound(f"Backup {backup_id} not found") + + async def _list_backups(self) -> dict[str, AgentBackup]: + """List backups, using a cache if possible.""" + if time() <= self._cache_expiration: + return self._backup_cache + + backups = {} + response = await self._client.list_objects_v2(Bucket=self._bucket) + + # Filter for metadata files only + metadata_files = [ + obj + for obj in response.get("Contents", []) + if obj["Key"].endswith(".metadata.json") + ] + + for metadata_file in metadata_files: + try: + # Download and parse metadata file + metadata_response = await self._client.get_object( + Bucket=self._bucket, Key=metadata_file["Key"] + ) + metadata_content = await metadata_response["Body"].read() + metadata_json = json.loads(metadata_content) + except (BotoCoreError, json.JSONDecodeError) as err: + _LOGGER.warning( + "Failed to process metadata file %s: %s", + metadata_file["Key"], + err, + ) + continue + backup = AgentBackup.from_dict(metadata_json) + backups[backup.backup_id] = backup + + self._backup_cache = backups + self._cache_expiration = time() + CACHE_TTL + + return self._backup_cache diff --git a/homeassistant/components/aws_s3/config_flow.py b/homeassistant/components/aws_s3/config_flow.py new file mode 100644 index 00000000000..a4de192e513 --- /dev/null +++ b/homeassistant/components/aws_s3/config_flow.py @@ -0,0 +1,101 @@ +"""Config flow for the AWS S3 integration.""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import urlparse + +from aiobotocore.session import AioSession +from botocore.exceptions import ClientError, ConnectionError, ParamValidationError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import ( + AWS_DOMAIN, + CONF_ACCESS_KEY_ID, + CONF_BUCKET, + CONF_ENDPOINT_URL, + CONF_SECRET_ACCESS_KEY, + DEFAULT_ENDPOINT_URL, + DESCRIPTION_AWS_S3_DOCS_URL, + DESCRIPTION_BOTO3_DOCS_URL, + DOMAIN, +) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_ACCESS_KEY_ID): cv.string, + vol.Required(CONF_SECRET_ACCESS_KEY): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Required(CONF_BUCKET): cv.string, + vol.Required(CONF_ENDPOINT_URL, default=DEFAULT_ENDPOINT_URL): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.URL) + ), + } +) + + +class S3ConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match( + { + CONF_BUCKET: user_input[CONF_BUCKET], + CONF_ENDPOINT_URL: user_input[CONF_ENDPOINT_URL], + } + ) + + if not urlparse(user_input[CONF_ENDPOINT_URL]).hostname.endswith( + AWS_DOMAIN + ): + errors[CONF_ENDPOINT_URL] = "invalid_endpoint_url" + else: + try: + session = AioSession() + async with session.create_client( + "s3", + endpoint_url=user_input.get(CONF_ENDPOINT_URL), + aws_secret_access_key=user_input[CONF_SECRET_ACCESS_KEY], + aws_access_key_id=user_input[CONF_ACCESS_KEY_ID], + ) as client: + await client.head_bucket(Bucket=user_input[CONF_BUCKET]) + except ClientError: + errors["base"] = "invalid_credentials" + except ParamValidationError as err: + if "Invalid bucket name" in str(err): + errors[CONF_BUCKET] = "invalid_bucket_name" + except ValueError: + errors[CONF_ENDPOINT_URL] = "invalid_endpoint_url" + except ConnectionError: + errors[CONF_ENDPOINT_URL] = "cannot_connect" + else: + return self.async_create_entry( + title=user_input[CONF_BUCKET], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + description_placeholders={ + "aws_s3_docs_url": DESCRIPTION_AWS_S3_DOCS_URL, + "boto3_docs_url": DESCRIPTION_BOTO3_DOCS_URL, + }, + ) diff --git a/homeassistant/components/aws_s3/const.py b/homeassistant/components/aws_s3/const.py new file mode 100644 index 00000000000..a6863e6c38a --- /dev/null +++ b/homeassistant/components/aws_s3/const.py @@ -0,0 +1,23 @@ +"""Constants for the AWS S3 integration.""" + +from collections.abc import Callable +from typing import Final + +from homeassistant.util.hass_dict import HassKey + +DOMAIN: Final = "aws_s3" + +CONF_ACCESS_KEY_ID = "access_key_id" +CONF_SECRET_ACCESS_KEY = "secret_access_key" +CONF_ENDPOINT_URL = "endpoint_url" +CONF_BUCKET = "bucket" + +AWS_DOMAIN = "amazonaws.com" +DEFAULT_ENDPOINT_URL = f"https://s3.eu-central-1.{AWS_DOMAIN}/" + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) + +DESCRIPTION_AWS_S3_DOCS_URL = "https://docs.aws.amazon.com/general/latest/gr/s3.html" +DESCRIPTION_BOTO3_DOCS_URL = "https://boto3.amazonaws.com/v1/documentation/api/latest/reference/core/session.html" diff --git a/homeassistant/components/aws_s3/manifest.json b/homeassistant/components/aws_s3/manifest.json new file mode 100644 index 00000000000..8ab65b5883a --- /dev/null +++ b/homeassistant/components/aws_s3/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "aws_s3", + "name": "AWS S3", + "codeowners": ["@tomasbedrich"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/aws_s3", + "integration_type": "service", + "iot_class": "cloud_push", + "loggers": ["aiobotocore"], + "quality_scale": "bronze", + "requirements": ["aiobotocore==2.21.1"] +} diff --git a/homeassistant/components/aws_s3/quality_scale.yaml b/homeassistant/components/aws_s3/quality_scale.yaml new file mode 100644 index 00000000000..11093f4430f --- /dev/null +++ b/homeassistant/components/aws_s3/quality_scale.yaml @@ -0,0 +1,112 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: This 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: This integration does not have any custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Entities of this integration does not explicitly subscribe to events. + entity-unique-id: + status: exempt + comment: This integration does not have entities. + has-entity-name: + status: exempt + comment: This integration does not have entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not have an options flow. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: This integration does not have entities. + integration-owner: done + log-when-unavailable: todo + parallel-updates: + status: exempt + comment: This integration does not poll. + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: + status: exempt + comment: This integration does not have entities. + diagnostics: todo + discovery-update-info: + status: exempt + comment: S3 is a cloud service that is not discovered on the network. + discovery: + status: exempt + comment: S3 is a cloud service that is not discovered on the network. + docs-data-update: + status: exempt + comment: This integration does not poll. + docs-examples: + status: exempt + comment: The integration extends core functionality and does not require examples. + docs-known-limitations: + status: exempt + comment: No known limitations. + docs-supported-devices: + status: exempt + comment: This integration does not support physical devices. + docs-supported-functions: done + docs-troubleshooting: + status: exempt + comment: There are no more detailed troubleshooting instructions available than what is already included in strings.json. + docs-use-cases: done + dynamic-devices: + status: exempt + comment: This integration does not have devices. + entity-category: + status: exempt + comment: This integration does not have entities. + entity-device-class: + status: exempt + comment: This integration does not have entities. + entity-disabled-by-default: + status: exempt + comment: This integration does not have entities. + entity-translations: + status: exempt + comment: This integration does not have entities. + exception-translations: done + icon-translations: + status: exempt + comment: This integration does not use icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: There are no issues which can be repaired. + stale-devices: + status: exempt + comment: This integration does not have devices. + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/aws_s3/strings.json b/homeassistant/components/aws_s3/strings.json new file mode 100644 index 00000000000..84a7f68c850 --- /dev/null +++ b/homeassistant/components/aws_s3/strings.json @@ -0,0 +1,41 @@ +{ + "config": { + "step": { + "user": { + "data": { + "access_key_id": "Access key ID", + "secret_access_key": "Secret access key", + "bucket": "Bucket name", + "endpoint_url": "Endpoint URL" + }, + "data_description": { + "access_key_id": "Access key ID to connect to AWS S3 API", + "secret_access_key": "Secret access key to connect to AWS S3 API", + "bucket": "Bucket must already exist and be writable by the provided credentials.", + "endpoint_url": "Endpoint URL provided to [Boto3 Session]({boto3_docs_url}). Region-specific [AWS S3 endpoints]({aws_s3_docs_url}) are available in their docs." + }, + "title": "Add AWS S3 bucket" + } + }, + "error": { + "cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]", + "invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::message%]", + "invalid_credentials": "[%key:component::aws_s3::exceptions::invalid_credentials::message%]", + "invalid_endpoint_url": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "exceptions": { + "cannot_connect": { + "message": "Cannot connect to endpoint" + }, + "invalid_bucket_name": { + "message": "Invalid bucket name" + }, + "invalid_credentials": { + "message": "Bucket cannot be accessed using provided combination of access key ID and secret access key." + } + } +} diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 9f801882387..8b4a1d4f5f5 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Mapping from ipaddress import ip_address -from types import MappingProxyType from typing import Any from urllib.parse import urlsplit @@ -88,7 +87,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN): if user_input is not None: try: - api = await get_axis_api(self.hass, MappingProxyType(user_input)) + api = await get_axis_api(self.hass, user_input) except AuthenticationRequired: errors["base"] = "invalid_auth" diff --git a/homeassistant/components/axis/hub/api.py b/homeassistant/components/axis/hub/api.py index 8e5d7533631..f33e925929c 100644 --- a/homeassistant/components/axis/hub/api.py +++ b/homeassistant/components/axis/hub/api.py @@ -1,7 +1,7 @@ """Axis network device abstraction.""" from asyncio import timeout -from types import MappingProxyType +from collections.abc import Mapping from typing import Any import axis @@ -23,7 +23,7 @@ from ..errors import AuthenticationRequired, CannotConnect async def get_axis_api( hass: HomeAssistant, - config: MappingProxyType[str, Any], + config: Mapping[str, Any], ) -> axis.AxisDevice: """Create a Axis device API.""" session = get_async_client(hass, verify_ssl=False) diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py index abe6cdfe15f..6a035e664d4 100644 --- a/homeassistant/components/azure_event_hub/__init__.py +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -3,11 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import datetime import json import logging -from types import MappingProxyType from typing import Any from azure.eventhub import EventData, EventDataBatch @@ -179,7 +178,7 @@ class AzureEventHub: await self.async_send(None) await self._queue.join() - def update_options(self, new_options: MappingProxyType[str, Any]) -> None: + def update_options(self, new_options: Mapping[str, Any]) -> None: """Update options.""" self._send_interval = new_options[CONF_SEND_INTERVAL] diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index f4fa2e8bac6..0c8a5c82f7c 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections import defaultdict from dataclasses import dataclass, field, replace import datetime as dt from datetime import datetime, timedelta @@ -87,12 +88,26 @@ class BackupConfigData: else: time = None days = [Day(day) for day in data["schedule"]["days"]] + agents = {} + for agent_id, agent_data in data["agents"].items(): + protected = agent_data["protected"] + stored_retention = agent_data["retention"] + agent_retention: AgentRetentionConfig | None + if stored_retention: + agent_retention = AgentRetentionConfig( + copies=stored_retention["copies"], + days=stored_retention["days"], + ) + else: + agent_retention = None + agent_config = AgentConfig( + protected=protected, + retention=agent_retention, + ) + agents[agent_id] = agent_config return cls( - agents={ - agent_id: AgentConfig(protected=agent_data["protected"]) - for agent_id, agent_data in data["agents"].items() - }, + agents=agents, automatic_backups_configured=data["automatic_backups_configured"], create_backup=CreateBackupConfig( agent_ids=data["create_backup"]["agent_ids"], @@ -176,12 +191,36 @@ class BackupConfig: """Update config.""" if agents is not UNDEFINED: for agent_id, agent_config in agents.items(): - if agent_id not in self.data.agents: - self.data.agents[agent_id] = AgentConfig(**agent_config) + agent_retention = agent_config.get("retention") + if agent_retention is None: + new_agent_retention = None else: - self.data.agents[agent_id] = replace( - self.data.agents[agent_id], **agent_config + new_agent_retention = AgentRetentionConfig( + copies=agent_retention.get("copies"), + days=agent_retention.get("days"), ) + if agent_id not in self.data.agents: + old_agent_retention = None + self.data.agents[agent_id] = AgentConfig( + protected=agent_config.get("protected", True), + retention=new_agent_retention, + ) + else: + new_agent_config = self.data.agents[agent_id] + old_agent_retention = new_agent_config.retention + if "protected" in agent_config: + new_agent_config = replace( + new_agent_config, protected=agent_config["protected"] + ) + if "retention" in agent_config: + new_agent_config = replace( + new_agent_config, retention=new_agent_retention + ) + self.data.agents[agent_id] = new_agent_config + if new_agent_retention != old_agent_retention: + # There's a single retention application method + # for both global and agent retention settings. + self.data.retention.apply(self._manager) if automatic_backups_configured is not UNDEFINED: self.data.automatic_backups_configured = automatic_backups_configured if create_backup is not UNDEFINED: @@ -207,11 +246,24 @@ class AgentConfig: """Represent the config for an agent.""" protected: bool + """Agent protected configuration. + + If True, the agent backups are password protected. + """ + retention: AgentRetentionConfig | None = None + """Agent retention configuration. + + If None, the global retention configuration is used. + If not None, the global retention configuration is ignored for this agent. + If an agent retention configuration is set and both copies and days are None, + backups will be kept forever for that agent. + """ def to_dict(self) -> StoredAgentConfig: """Convert agent config to a dict.""" return { "protected": self.protected, + "retention": self.retention.to_dict() if self.retention else None, } @@ -219,24 +271,46 @@ class StoredAgentConfig(TypedDict): """Represent the stored config for an agent.""" protected: bool + retention: StoredRetentionConfig | None class AgentParametersDict(TypedDict, total=False): """Represent the parameters for an agent.""" protected: bool + retention: RetentionParametersDict | None @dataclass(kw_only=True) -class RetentionConfig: - """Represent the backup retention configuration.""" +class BaseRetentionConfig: + """Represent the base backup retention configuration.""" copies: int | None = None days: int | None = None + def to_dict(self) -> StoredRetentionConfig: + """Convert backup retention configuration to a dict.""" + return StoredRetentionConfig( + copies=self.copies, + days=self.days, + ) + + +@dataclass(kw_only=True) +class RetentionConfig(BaseRetentionConfig): + """Represent the backup retention configuration.""" + def apply(self, manager: BackupManager) -> None: """Apply backup retention configuration.""" - if self.days is not None: + agents_retention = { + agent_id: agent_config.retention + for agent_id, agent_config in manager.config.data.agents.items() + } + + if self.days is not None or any( + agent_retention and agent_retention.days is not None + for agent_retention in agents_retention.values() + ): LOGGER.debug( "Scheduling next automatic delete of backups older than %s in 1 day", self.days, @@ -246,13 +320,6 @@ class RetentionConfig: LOGGER.debug("Unscheduling next automatic delete") self._unschedule_next(manager) - def to_dict(self) -> StoredRetentionConfig: - """Convert backup retention configuration to a dict.""" - return StoredRetentionConfig( - copies=self.copies, - days=self.days, - ) - @callback def _schedule_next( self, @@ -271,16 +338,81 @@ class RetentionConfig: """Return backups older than days to delete.""" # we need to check here since we await before # this filter is applied - if self.days is None: - return {} - now = dt_util.utcnow() - return { - backup_id: backup - for backup_id, backup in backups.items() - if dt_util.parse_datetime(backup.date, raise_on_error=True) - + timedelta(days=self.days) - < now + agents_retention = { + agent_id: agent_config.retention + for agent_id, agent_config in manager.config.data.agents.items() } + has_agents_retention = any( + agent_retention for agent_retention in agents_retention.values() + ) + has_agents_retention_days = any( + agent_retention and agent_retention.days is not None + for agent_retention in agents_retention.values() + ) + if (global_days := self.days) is None and not has_agents_retention_days: + # No global retention days and no agent retention days + return {} + + now = dt_util.utcnow() + if global_days is not None and not has_agents_retention: + # Return early to avoid the longer filtering below. + return { + backup_id: backup + for backup_id, backup in backups.items() + if dt_util.parse_datetime(backup.date, raise_on_error=True) + + timedelta(days=global_days) + < now + } + + # If there are any agent retention settings, we need to check + # the retention settings, for every backup and agent combination. + + backups_to_delete = {} + + for backup_id, backup in backups.items(): + backup_date = dt_util.parse_datetime( + backup.date, raise_on_error=True + ) + delete_from_agents = set(backup.agents) + for agent_id in backup.agents: + agent_retention = agents_retention.get(agent_id) + if agent_retention is None: + # This agent does not have a retention setting, + # so the global retention setting should be used. + if global_days is None: + # This agent does not have a retention setting + # and the global retention days setting is None, + # so this backup should not be deleted. + delete_from_agents.discard(agent_id) + continue + days = global_days + elif (agent_days := agent_retention.days) is None: + # This agent has a retention setting + # where days is set to None, + # so the backup should not be deleted. + delete_from_agents.discard(agent_id) + continue + else: + # This agent has a retention setting + # where days is set to a number, + # so that setting should be used. + days = agent_days + if backup_date + timedelta(days=days) >= now: + # This backup is not older than the retention days, + # so this agent should not be deleted. + delete_from_agents.discard(agent_id) + + filtered_backup = replace( + backup, + agents={ + agent_id: agent_backup_status + for agent_id, agent_backup_status in backup.agents.items() + if agent_id in delete_from_agents + }, + ) + backups_to_delete[backup_id] = filtered_backup + + return backups_to_delete await manager.async_delete_filtered_backups( include_filter=_automatic_backups_filter, delete_filter=_delete_filter @@ -312,6 +444,10 @@ class RetentionParametersDict(TypedDict, total=False): days: int | None +class AgentRetentionConfig(BaseRetentionConfig): + """Represent an agent retention configuration.""" + + class StoredBackupSchedule(TypedDict): """Represent the stored backup schedule configuration.""" @@ -554,16 +690,87 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N backups: dict[str, ManagerBackup], ) -> dict[str, ManagerBackup]: """Return oldest backups more numerous than copies to delete.""" + agents_retention = { + agent_id: agent_config.retention + for agent_id, agent_config in manager.config.data.agents.items() + } + has_agents_retention = any( + agent_retention for agent_retention in agents_retention.values() + ) + has_agents_retention_copies = any( + agent_retention and agent_retention.copies is not None + for agent_retention in agents_retention.values() + ) # we need to check here since we await before # this filter is applied - if manager.config.data.retention.copies is None: + if ( + global_copies := manager.config.data.retention.copies + ) is None and not has_agents_retention_copies: + # No global retention copies and no agent retention copies return {} - return dict( - sorted( - backups.items(), - key=lambda backup_item: backup_item[1].date, - )[: max(len(backups) - manager.config.data.retention.copies, 0)] + if global_copies is not None and not has_agents_retention: + # Return early to avoid the longer filtering below. + return dict( + sorted( + backups.items(), + key=lambda backup_item: backup_item[1].date, + )[: max(len(backups) - global_copies, 0)] + ) + + backups_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict(dict) + for backup_id, backup in backups.items(): + for agent_id in backup.agents: + backups_by_agent[agent_id][backup_id] = backup + + backups_to_delete_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict( + dict ) + for agent_id, agent_backups in backups_by_agent.items(): + agent_retention = agents_retention.get(agent_id) + if agent_retention is None: + # This agent does not have a retention setting, + # so the global retention setting should be used. + if global_copies is None: + # This agent does not have a retention setting + # and the global retention copies setting is None, + # so backups should not be deleted. + continue + # The global retention setting will be used. + copies = global_copies + elif (agent_copies := agent_retention.copies) is None: + # This agent has a retention setting + # where copies is set to None, + # so backups should not be deleted. + continue + else: + # This agent retention setting will be used. + copies = agent_copies + + backups_to_delete_by_agent[agent_id] = dict( + sorted( + agent_backups.items(), + key=lambda backup_item: backup_item[1].date, + )[: max(len(agent_backups) - copies, 0)] + ) + + backup_ids_to_delete: dict[str, set[str]] = defaultdict(set) + for agent_id, to_delete in backups_to_delete_by_agent.items(): + for backup_id in to_delete: + backup_ids_to_delete[backup_id].add(agent_id) + backups_to_delete: dict[str, ManagerBackup] = {} + for backup_id, agent_ids in backup_ids_to_delete.items(): + backup = backups[backup_id] + # filter the backup to only include the agents that should be deleted + filtered_backup = replace( + backup, + agents={ + agent_id: agent_backup_status + for agent_id, agent_backup_status in backup.agents.items() + if agent_id in agent_ids + }, + ) + backups_to_delete[backup_id] = filtered_backup + return backups_to_delete await manager.async_delete_filtered_backups( include_filter=_automatic_backups_filter, delete_filter=_delete_filter diff --git a/homeassistant/components/backup/onboarding.py b/homeassistant/components/backup/onboarding.py new file mode 100644 index 00000000000..ad7027c988c --- /dev/null +++ b/homeassistant/components/backup/onboarding.py @@ -0,0 +1,136 @@ +"""Backup onboarding views.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from functools import wraps +from http import HTTPStatus +from typing import TYPE_CHECKING, Any, Concatenate + +from aiohttp import web +from aiohttp.web_exceptions import HTTPUnauthorized +import voluptuous as vol + +from homeassistant.components.http import KEY_HASS +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.onboarding import ( + BaseOnboardingView, + NoAuthBaseOnboardingView, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager + +from . import BackupManager, Folder, IncorrectPasswordError, http as backup_http + +if TYPE_CHECKING: + from homeassistant.components.onboarding import OnboardingStoreData + + +async def async_setup_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: + """Set up the backup views.""" + + hass.http.register_view(BackupInfoView(data)) + hass.http.register_view(RestoreBackupView(data)) + hass.http.register_view(UploadBackupView(data)) + + +def with_backup_manager[_ViewT: BaseOnboardingView, **_P]( + func: Callable[ + Concatenate[_ViewT, BackupManager, web.Request, _P], + Coroutine[Any, Any, web.Response], + ], +) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]: + """Home Assistant API decorator to check onboarding and inject manager.""" + + @wraps(func) + async def with_backup( + self: _ViewT, + request: web.Request, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> web.Response: + """Check admin and call function.""" + if self._data["done"]: + raise HTTPUnauthorized + + manager = await async_get_backup_manager(request.app[KEY_HASS]) + return await func(self, manager, request, *args, **kwargs) + + return with_backup + + +class BackupInfoView(NoAuthBaseOnboardingView): + """Get backup info view.""" + + url = "/api/onboarding/backup/info" + name = "api:onboarding:backup:info" + + @with_backup_manager + async def get(self, manager: BackupManager, request: web.Request) -> web.Response: + """Return backup info.""" + backups, _ = await manager.async_get_backups() + return self.json( + { + "backups": list(backups.values()), + "state": manager.state, + "last_action_event": manager.last_action_event, + } + ) + + +class RestoreBackupView(NoAuthBaseOnboardingView): + """Restore backup view.""" + + url = "/api/onboarding/backup/restore" + name = "api:onboarding:backup:restore" + + @RequestDataValidator( + vol.Schema( + { + vol.Required("backup_id"): str, + vol.Required("agent_id"): str, + vol.Optional("password"): str, + vol.Optional("restore_addons"): [str], + vol.Optional("restore_database", default=True): bool, + vol.Optional("restore_folders"): [vol.Coerce(Folder)], + } + ) + ) + @with_backup_manager + async def post( + self, manager: BackupManager, request: web.Request, data: dict[str, Any] + ) -> web.Response: + """Restore a backup.""" + try: + await manager.async_restore_backup( + data["backup_id"], + agent_id=data["agent_id"], + password=data.get("password"), + restore_addons=data.get("restore_addons"), + restore_database=data["restore_database"], + restore_folders=data.get("restore_folders"), + restore_homeassistant=True, + ) + except IncorrectPasswordError: + return self.json( + {"code": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST + ) + except HomeAssistantError as err: + return self.json( + {"code": "restore_failed", "message": str(err)}, + status_code=HTTPStatus.BAD_REQUEST, + ) + return web.Response(status=HTTPStatus.OK) + + +class UploadBackupView(NoAuthBaseOnboardingView, backup_http.UploadBackupView): + """Upload backup view.""" + + url = "/api/onboarding/backup/upload" + name = "api:onboarding:backup:upload" + + @with_backup_manager + async def post(self, manager: BackupManager, request: web.Request) -> web.Response: + """Upload a backup file.""" + return await self._post(request) diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 883447853e6..6472f8ae151 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: STORE_DELAY_SAVE = 30 STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 5 +STORAGE_VERSION_MINOR = 6 class StoredBackupData(TypedDict): @@ -72,6 +72,10 @@ class _BackupStore(Store[StoredBackupData]): data["config"]["automatic_backups_configured"] = ( data["config"]["create_backup"]["password"] is not None ) + if old_minor_version < 6: + # Version 1.6 adds agent retention settings + for agent in data["config"]["agents"]: + data["config"]["agents"][agent]["retention"] = None # Note: We allow reading data with major version 2. # Reject if major version is higher than 2. diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json index 487fdd89a7c..357bcdbb72f 100644 --- a/homeassistant/components/backup/strings.json +++ b/homeassistant/components/backup/strings.json @@ -26,9 +26,9 @@ "entity": { "sensor": { "backup_manager_state": { - "name": "Backup Manager State", + "name": "Backup Manager state", "state": { - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "create_backup": "Creating a backup", "receive_backup": "Receiving a backup", "restore_backup": "Restoring a backup" diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 4c370a4224d..080b5bb18a8 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -346,7 +346,28 @@ async def handle_config_info( @websocket_api.websocket_command( { vol.Required("type"): "backup/config/update", - vol.Optional("agents"): vol.Schema({str: {"protected": bool}}), + vol.Optional("agents"): vol.Schema( + { + str: { + vol.Optional("protected"): bool, + vol.Optional("retention"): vol.Any( + vol.Schema( + { + # Note: We can't use cv.positive_int because it allows 0 even + # though 0 is not positive. + vol.Optional("copies"): vol.Any( + vol.All(int, vol.Range(min=1)), None + ), + vol.Optional("days"): vol.Any( + vol.All(int, vol.Range(min=1)), None + ), + }, + ), + None, + ), + } + } + ), vol.Optional("automatic_backups_configured"): bool, vol.Optional("create_backup"): vol.Schema( { diff --git a/homeassistant/components/baf/strings.json b/homeassistant/components/baf/strings.json index 64956984bb8..629a3041df5 100644 --- a/homeassistant/components/baf/strings.json +++ b/homeassistant/components/baf/strings.json @@ -31,7 +31,7 @@ "state_attributes": { "preset_mode": { "state": { - "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]" + "auto": "[%key:common::state::auto%]" } } } diff --git a/homeassistant/components/balay/__init__.py b/homeassistant/components/balay/__init__.py new file mode 100644 index 00000000000..e7fa8bba86d --- /dev/null +++ b/homeassistant/components/balay/__init__.py @@ -0,0 +1 @@ +"""Balay virtual integration.""" diff --git a/homeassistant/components/balay/manifest.json b/homeassistant/components/balay/manifest.json new file mode 100644 index 00000000000..98e4f521c7a --- /dev/null +++ b/homeassistant/components/balay/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "balay", + "name": "Balay", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 784ce8533a8..8297e2e3b9f 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -103,8 +103,8 @@ "temperature_range": { "name": "Temperature range", "state": { - "low": "Low", - "high": "High" + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" } } }, diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index b86a6374f28..ea897ed1c49 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -124,15 +124,15 @@ "battery": { "name": "Battery", "state": { - "off": "Normal", - "on": "Low" + "off": "[%key:common::state::normal%]", + "on": "[%key:common::state::low%]" } }, "battery_charging": { "name": "Charging", "state": { "off": "Not charging", - "on": "Charging" + "on": "[%key:common::state::charging%]" } }, "carbon_monoxide": { @@ -145,7 +145,7 @@ "cold": { "name": "Cold", "state": { - "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", + "off": "[%key:common::state::normal%]", "on": "Cold" } }, @@ -180,7 +180,7 @@ "heat": { "name": "Heat", "state": { - "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", + "off": "[%key:common::state::normal%]", "on": "Hot" } }, diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 2e48d768a74..a8a9aff7f08 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -30,18 +30,18 @@ "available": "Available", "charging": "[%key:common::state::charging%]", "unavailable": "Unavailable", - "error": "Error", + "error": "[%key:common::state::error%]", "offline": "Offline" } }, "vehicle_status": { "name": "Vehicle status", "state": { - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "vehicle_detected": "Detected", "ready": "Ready", "no_power": "No power", - "vehicle_error": "Error" + "vehicle_error": "[%key:common::state::error%]" } }, "actual_v1": { diff --git a/homeassistant/components/bluemaestro/manifest.json b/homeassistant/components/bluemaestro/manifest.json index 8d2ff3b96f9..887b27239ef 100644 --- a/homeassistant/components/bluemaestro/manifest.json +++ b/homeassistant/components/bluemaestro/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bluemaestro", "iot_class": "local_push", - "requirements": ["bluemaestro-ble==0.2.3"] + "requirements": ["bluemaestro-ble==0.4.1"] } diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index 151c1512b74..caf5cc7541d 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bluesound", "iot_class": "local_polling", - "requirements": ["pyblu==2.0.0"], + "requirements": ["pyblu==2.0.1"], "zeroconf": [ { "type": "_musc._tcp.local." diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 0addcc1daac..337dc3d3a33 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -330,7 +330,12 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity if self._status.input_id is not None: for input_ in self._inputs: - if input_.id == self._status.input_id: + # the input might not have an id => also try to match on the stream_url/url + # we have to use both because neither matches all the time + if ( + input_.id == self._status.input_id + or input_.url == self._status.stream_url + ): return input_.text for preset in self._presets: diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 1b2b0e7267b..f9377443296 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,9 +18,9 @@ "bleak==0.22.3", "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", - "bluetooth-auto-recovery==1.4.5", - "bluetooth-data-tools==1.26.5", + "bluetooth-auto-recovery==1.5.1", + "bluetooth-data-tools==1.28.1", "dbus-fast==2.43.0", - "habluetooth==3.37.0" + "habluetooth==3.48.2" ] } diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 8f66a3582ea..09e953a8676 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -374,6 +374,27 @@ class PassiveBluetoothProcessorCoordinator[_DataT](BasePassiveBluetoothCoordinat self.logger.exception("Unexpected error updating %s data", self.name) return + self._process_update(update, was_available) + + @callback + def async_set_updated_data(self, update: _DataT) -> None: + """Manually update the processor with new data. + + If the data comes in via a different method, like a + notification, this method can be used to update the + processor with the new data. + + This is useful for devices that retrieve + some of their data via notifications. + """ + was_available = self._available + self._available = True + self._process_update(update, was_available) + + def _process_update( + self, update: _DataT, was_available: bool | None = None + ) -> None: + """Process the update from the bluetooth device.""" if not self.last_update_success: self.last_update_success = True self.logger.info("Coordinator %s recovered", self.name) diff --git a/homeassistant/components/bluetooth/storage.py b/homeassistant/components/bluetooth/storage.py index 369db4a7760..3222eaef2c5 100644 --- a/homeassistant/components/bluetooth/storage.py +++ b/homeassistant/components/bluetooth/storage.py @@ -2,7 +2,7 @@ from __future__ import annotations -from bluetooth_adapters import ( +from habluetooth import ( DiscoveredDeviceAdvertisementData, DiscoveredDeviceAdvertisementDataDict, DiscoveryStorageType, diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 4b16b719d8d..d094116725f 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -6,7 +6,7 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "region": "ConnectedDrive Region" + "region": "ConnectedDrive region" }, "data_description": { "username": "The email address of your MyBMW/MINI Connected account.", @@ -113,10 +113,10 @@ }, "select": { "ac_limit": { - "name": "AC Charging Limit" + "name": "AC charging limit" }, "charging_mode": { - "name": "Charging Mode", + "name": "Charging mode", "state": { "immediate_charging": "Immediate charging", "delayed_charging": "Delayed charging", @@ -139,7 +139,7 @@ "state": { "default": "Default", "charging": "[%key:common::state::charging%]", - "error": "Error", + "error": "[%key:common::state::error%]", "complete": "Complete", "fully_charged": "Fully charged", "finished_fully_charged": "Finished, fully charged", @@ -181,7 +181,7 @@ "cooling": "Cooling", "heating": "Heating", "inactive": "Inactive", - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "ventilation": "Ventilation" } }, diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 38abd63186a..ffa0098840c 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -16,6 +16,7 @@ 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.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -91,11 +92,22 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered[CONF_ACCESS_TOKEN] = token try: - _, hub_name = await _validate_input(self.hass, self._discovered) + bond_id, hub_name = await _validate_input(self.hass, self._discovered) except InputValidationError: return + await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._discovered[CONF_NAME] = hub_name + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by dhcp discovery.""" + host = discovery_info.ip + bond_id = discovery_info.hostname.partition("-")[2].upper() + await self.async_set_unique_id(bond_id) + return await self.async_step_any_discovery(bond_id, host) + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: @@ -104,11 +116,17 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): host: str = discovery_info.host bond_id = name.partition(".")[0] await self.async_set_unique_id(bond_id) + return await self.async_step_any_discovery(bond_id, host) + + async def async_step_any_discovery( + self, bond_id: str, host: str + ) -> ConfigFlowResult: + """Handle a flow initialized by discovery.""" for entry in self._async_current_entries(): if entry.unique_id != bond_id: continue updates = {CONF_HOST: host} - if entry.state == ConfigEntryState.SETUP_ERROR and ( + if entry.state is ConfigEntryState.SETUP_ERROR and ( token := await async_get_token(self.hass, host) ): updates[CONF_ACCESS_TOKEN] = token @@ -153,10 +171,14 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: self._discovered[CONF_HOST], } try: - _, hub_name = await _validate_input(self.hass, data) + bond_id, hub_name = await _validate_input(self.hass, data) except InputValidationError as error: errors["base"] = error.base else: + await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self._discovered[CONF_HOST]} + ) return self.async_create_entry( title=hub_name, data=data, @@ -185,8 +207,10 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): except InputValidationError as error: errors["base"] = error.base else: - await self.async_set_unique_id(bond_id) - self._abort_if_unique_id_configured() + await self.async_set_unique_id(bond_id, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates={CONF_HOST: user_input[CONF_HOST]} + ) return self.async_create_entry(title=hub_name, data=user_input) return self.async_show_form( diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 1d4c110f4fd..704b9934970 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -3,6 +3,16 @@ "name": "Bond", "codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"], "config_flow": true, + "dhcp": [ + { + "hostname": "bond-*", + "macaddress": "3C6A2C1*" + }, + { + "hostname": "bond-*", + "macaddress": "F44E38*" + } + ], "documentation": "https://www.home-assistant.io/integrations/bond", "iot_class": "local_push", "loggers": ["bond_async"], diff --git a/homeassistant/components/bosch_alarm/__init__.py b/homeassistant/components/bosch_alarm/__init__.py index bc7fee46f60..602c801701d 100644 --- a/homeassistant/components/bosch_alarm/__init__.py +++ b/homeassistant/components/bosch_alarm/__init__.py @@ -9,12 +9,12 @@ from bosch_alarm_mode2 import Panel from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN -PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL] +PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL, Platform.SENSOR] type BoschAlarmConfigEntry = ConfigEntry[Panel] @@ -34,10 +34,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) - await panel.connect() except (PermissionError, ValueError) as err: await panel.disconnect() - raise ConfigEntryNotReady from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err: await panel.disconnect() - raise ConfigEntryNotReady("Connection failed") from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err entry.runtime_data = panel diff --git a/homeassistant/components/bosch_alarm/alarm_control_panel.py b/homeassistant/components/bosch_alarm/alarm_control_panel.py index a1d8a7b90f4..2854298f815 100644 --- a/homeassistant/components/bosch_alarm/alarm_control_panel.py +++ b/homeassistant/components/bosch_alarm/alarm_control_panel.py @@ -10,11 +10,10 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BoschAlarmConfigEntry -from .const import DOMAIN +from .entity import BoschAlarmAreaEntity async def async_setup_entry( @@ -35,7 +34,7 @@ async def async_setup_entry( ) -class AreaAlarmControlPanel(AlarmControlPanelEntity): +class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity): """An alarm control panel entity for a bosch alarm panel.""" _attr_has_entity_name = True @@ -48,19 +47,8 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity): def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None: """Initialise a Bosch Alarm control panel entity.""" - self.panel = panel - self._area = panel.areas[area_id] - self._area_id = area_id - self._attr_unique_id = f"{unique_id}_area_{area_id}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._attr_unique_id)}, - name=self._area.name, - manufacturer="Bosch Security Systems", - via_device=( - DOMAIN, - unique_id, - ), - ) + super().__init__(panel, area_id, unique_id, False, False, True) + self._attr_unique_id = self._area_unique_id @property def alarm_state(self) -> AlarmControlPanelState | None: @@ -90,20 +78,3 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity): async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self.panel.area_arm_all(self._area_id) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.panel.connection_status() - - async def async_added_to_hass(self) -> None: - """Run when entity attached to hass.""" - await super().async_added_to_hass() - self._area.status_observer.attach(self.schedule_update_ha_state) - self.panel.connection_status_observer.attach(self.schedule_update_ha_state) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity removed from hass.""" - await super().async_will_remove_from_hass() - self._area.status_observer.detach(self.schedule_update_ha_state) - self.panel.connection_status_observer.detach(self.schedule_update_ha_state) diff --git a/homeassistant/components/bosch_alarm/config_flow.py b/homeassistant/components/bosch_alarm/config_flow.py index e48f2a11944..9e664e49ca9 100644 --- a/homeassistant/components/bosch_alarm/config_flow.py +++ b/homeassistant/components/bosch_alarm/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import logging import ssl from typing import Any @@ -10,7 +11,12 @@ from typing import Any from bosch_alarm_mode2 import Panel import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import ( CONF_CODE, CONF_HOST, @@ -107,6 +113,13 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): else: self._data = user_input self._data[CONF_MODEL] = model + + if self.source == SOURCE_RECONFIGURE: + if ( + self._get_reconfigure_entry().data[CONF_MODEL] + != self._data[CONF_MODEL] + ): + return self.async_abort(reason="device_mismatch") return await self.async_step_auth() return self.async_show_form( step_id="user", @@ -116,6 +129,12 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the reconfigure step.""" + return await self.async_step_user() + async def async_step_auth( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -153,13 +172,77 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): else: if serial_number: await self.async_set_unique_id(str(serial_number)) - self._abort_if_unique_id_configured() - else: - self._async_abort_entries_match({CONF_HOST: self._data[CONF_HOST]}) - return self.async_create_entry(title=f"Bosch {model}", data=self._data) + if self.source == SOURCE_USER: + if serial_number: + self._abort_if_unique_id_configured() + else: + self._async_abort_entries_match( + {CONF_HOST: self._data[CONF_HOST]} + ) + return self.async_create_entry( + title=f"Bosch {model}", data=self._data + ) + if serial_number: + self._abort_if_unique_id_mismatch(reason="device_mismatch") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data=self._data, + ) return self.async_show_form( step_id="auth", data_schema=self.add_suggested_values_to_schema(schema, user_input), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an authentication error.""" + self._data = dict(entry_data) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the reauth step.""" + errors: dict[str, str] = {} + + # Each model variant requires a different authentication flow + if "Solution" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_SOLUTION + elif "AMAX" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_AMAX + else: + schema = STEP_AUTH_DATA_SCHEMA_BG + + if user_input is not None: + reauth_entry = self._get_reauth_entry() + self._data.update(user_input) + try: + (_, _) = await try_connect(self._data, Panel.LOAD_EXTENDED_INFO) + except (PermissionError, ValueError) as e: + errors["base"] = "invalid_auth" + _LOGGER.error("Authentication Error: %s", e) + except ( + OSError, + ConnectionRefusedError, + ssl.SSLError, + TimeoutError, + ) as e: + _LOGGER.error("Connection Error: %s", e) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema(schema, user_input), + errors=errors, + ) diff --git a/homeassistant/components/bosch_alarm/diagnostics.py b/homeassistant/components/bosch_alarm/diagnostics.py new file mode 100644 index 00000000000..2e93052ea95 --- /dev/null +++ b/homeassistant/components/bosch_alarm/diagnostics.py @@ -0,0 +1,73 @@ +"""Diagnostics for bosch alarm.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from . import BoschAlarmConfigEntry +from .const import CONF_INSTALLER_CODE, CONF_USER_CODE + +TO_REDACT = [CONF_INSTALLER_CODE, CONF_USER_CODE, CONF_PASSWORD] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: BoschAlarmConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "data": { + "model": entry.runtime_data.model, + "serial_number": entry.runtime_data.serial_number, + "protocol_version": entry.runtime_data.protocol_version, + "firmware_version": entry.runtime_data.firmware_version, + "areas": [ + { + "id": area_id, + "name": area.name, + "all_ready": area.all_ready, + "part_ready": area.part_ready, + "faults": area.faults, + "alarms": area.alarms, + "disarmed": area.is_disarmed(), + "arming": area.is_arming(), + "pending": area.is_pending(), + "part_armed": area.is_part_armed(), + "all_armed": area.is_all_armed(), + "armed": area.is_armed(), + "triggered": area.is_triggered(), + } + for area_id, area in entry.runtime_data.areas.items() + ], + "points": [ + { + "id": point_id, + "name": point.name, + "open": point.is_open(), + "normal": point.is_normal(), + } + for point_id, point in entry.runtime_data.points.items() + ], + "doors": [ + { + "id": door_id, + "name": door.name, + "open": door.is_open(), + "locked": door.is_locked(), + } + for door_id, door in entry.runtime_data.doors.items() + ], + "outputs": [ + { + "id": output_id, + "name": output.name, + "active": output.is_active(), + } + for output_id, output in entry.runtime_data.outputs.items() + ], + "history_events": entry.runtime_data.events, + }, + } diff --git a/homeassistant/components/bosch_alarm/entity.py b/homeassistant/components/bosch_alarm/entity.py new file mode 100644 index 00000000000..f74634125c4 --- /dev/null +++ b/homeassistant/components/bosch_alarm/entity.py @@ -0,0 +1,88 @@ +"""Support for Bosch Alarm Panel History as a sensor.""" + +from __future__ import annotations + +from bosch_alarm_mode2 import Panel + +from homeassistant.components.sensor import Entity +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import DOMAIN + +PARALLEL_UPDATES = 0 + + +class BoschAlarmEntity(Entity): + """A base entity for a bosch alarm panel.""" + + _attr_has_entity_name = True + + def __init__(self, panel: Panel, unique_id: str) -> None: + """Set up a entity for a bosch alarm panel.""" + self.panel = panel + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=f"Bosch {panel.model}", + manufacturer="Bosch Security Systems", + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.panel.connection_status() + + async def async_added_to_hass(self) -> None: + """Observe state changes.""" + self.panel.connection_status_observer.attach(self.schedule_update_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Stop observing state changes.""" + self.panel.connection_status_observer.detach(self.schedule_update_ha_state) + + +class BoschAlarmAreaEntity(BoschAlarmEntity): + """A base entity for area related entities within a bosch alarm panel.""" + + def __init__( + self, + panel: Panel, + area_id: int, + unique_id: str, + observe_alarms: bool, + observe_ready: bool, + observe_status: bool, + ) -> None: + """Set up a area related entity for a bosch alarm panel.""" + super().__init__(panel, unique_id) + self._area_id = area_id + self._area_unique_id = f"{unique_id}_area_{area_id}" + self._observe_alarms = observe_alarms + self._observe_ready = observe_ready + self._observe_status = observe_status + self._area = panel.areas[area_id] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._area_unique_id)}, + name=self._area.name, + manufacturer="Bosch Security Systems", + via_device=(DOMAIN, unique_id), + ) + + async def async_added_to_hass(self) -> None: + """Observe state changes.""" + await super().async_added_to_hass() + if self._observe_alarms: + self._area.alarm_observer.attach(self.schedule_update_ha_state) + if self._observe_ready: + self._area.ready_observer.attach(self.schedule_update_ha_state) + if self._observe_status: + self._area.status_observer.attach(self.schedule_update_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Stop observing state changes.""" + await super().async_added_to_hass() + if self._observe_alarms: + self._area.alarm_observer.detach(self.schedule_update_ha_state) + if self._observe_ready: + self._area.ready_observer.detach(self.schedule_update_ha_state) + if self._observe_status: + self._area.status_observer.detach(self.schedule_update_ha_state) diff --git a/homeassistant/components/bosch_alarm/icons.json b/homeassistant/components/bosch_alarm/icons.json new file mode 100644 index 00000000000..1e207310713 --- /dev/null +++ b/homeassistant/components/bosch_alarm/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "faulting_points": { + "default": "mdi:alert-circle-outline" + } + } + } +} diff --git a/homeassistant/components/bosch_alarm/manifest.json b/homeassistant/components/bosch_alarm/manifest.json index a54ace71782..eefcc400ee7 100644 --- a/homeassistant/components/bosch_alarm/manifest.json +++ b/homeassistant/components/bosch_alarm/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["bosch-alarm-mode2==0.4.3"] + "requirements": ["bosch-alarm-mode2==0.4.6"] } diff --git a/homeassistant/components/bosch_alarm/quality_scale.yaml b/homeassistant/components/bosch_alarm/quality_scale.yaml index 467760fb863..3a64667a407 100644 --- a/homeassistant/components/bosch_alarm/quality_scale.yaml +++ b/homeassistant/components/bosch_alarm/quality_scale.yaml @@ -40,7 +40,7 @@ rules: integration-owner: done log-when-unavailable: todo parallel-updates: todo - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold @@ -62,9 +62,9 @@ rules: entity-category: todo entity-device-class: todo entity-disabled-by-default: todo - entity-translations: todo + entity-translations: done exception-translations: todo - icon-translations: todo + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/bosch_alarm/sensor.py b/homeassistant/components/bosch_alarm/sensor.py new file mode 100644 index 00000000000..3d61c72a883 --- /dev/null +++ b/homeassistant/components/bosch_alarm/sensor.py @@ -0,0 +1,86 @@ +"""Support for Bosch Alarm Panel History as a sensor.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from bosch_alarm_mode2 import Panel +from bosch_alarm_mode2.panel import Area + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BoschAlarmConfigEntry +from .entity import BoschAlarmAreaEntity + + +@dataclass(kw_only=True, frozen=True) +class BoschAlarmSensorEntityDescription(SensorEntityDescription): + """Describes Bosch Alarm sensor entity.""" + + value_fn: Callable[[Area], int] + observe_alarms: bool = False + observe_ready: bool = False + observe_status: bool = False + + +SENSOR_TYPES: list[BoschAlarmSensorEntityDescription] = [ + BoschAlarmSensorEntityDescription( + key="faulting_points", + translation_key="faulting_points", + value_fn=lambda area: area.faults, + observe_ready=True, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BoschAlarmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up bosch alarm sensors.""" + + panel = config_entry.runtime_data + unique_id = config_entry.unique_id or config_entry.entry_id + + async_add_entities( + BoschAreaSensor(panel, area_id, unique_id, template) + for area_id in panel.areas + for template in SENSOR_TYPES + ) + + +PARALLEL_UPDATES = 0 + + +class BoschAreaSensor(BoschAlarmAreaEntity, SensorEntity): + """An area sensor entity for a bosch alarm panel.""" + + entity_description: BoschAlarmSensorEntityDescription + + def __init__( + self, + panel: Panel, + area_id: int, + unique_id: str, + entity_description: BoschAlarmSensorEntityDescription, + ) -> None: + """Set up an area sensor entity for a bosch alarm panel.""" + super().__init__( + panel, + area_id, + unique_id, + entity_description.observe_alarms, + entity_description.observe_ready, + entity_description.observe_status, + ) + self.entity_description = entity_description + self._attr_unique_id = f"{self._area_unique_id}_{entity_description.key}" + + @property + def native_value(self) -> int: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self._area) diff --git a/homeassistant/components/bosch_alarm/strings.json b/homeassistant/components/bosch_alarm/strings.json index f4846021b55..6b916dad4fa 100644 --- a/homeassistant/components/bosch_alarm/strings.json +++ b/homeassistant/components/bosch_alarm/strings.json @@ -22,6 +22,18 @@ "installer_code": "The installer code from your panel", "user_code": "The user code from your panel" } + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "installer_code": "[%key:component::bosch_alarm::config::step::auth::data::installer_code%]", + "user_code": "[%key:component::bosch_alarm::config::step::auth::data::user_code%]" + }, + "data_description": { + "password": "[%key:component::bosch_alarm::config::step::auth::data_description::password%]", + "installer_code": "[%key:component::bosch_alarm::config::step::auth::data_description::installer_code%]", + "user_code": "[%key:component::bosch_alarm::config::step::auth::data_description::user_code%]" + } } }, "error": { @@ -30,7 +42,26 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "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%]", + "device_mismatch": "Please ensure you reconfigure against the same device." + } + }, + "exceptions": { + "cannot_connect": { + "message": "Could not connect to panel." + }, + "authentication_failed": { + "message": "Incorrect credentials for panel." + } + }, + "entity": { + "sensor": { + "faulting_points": { + "name": "Faulting points", + "unit_of_measurement": "points" + } } } } diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index 1dbe0adbf6c..2c30af5adce 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -13,7 +13,7 @@ }, "data_description": { "email": "The email address associated with your Bring! account.", - "password": "The password to login to your Bring! account." + "password": "The password to log in to your Bring! account." } }, "reauth_confirm": { diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index a7267320de3..4d54c95fd6c 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -12,6 +12,7 @@ from buienradar.constants import ( CONDITION, CONTENT, DATA, + FEELTEMPERATURE, FORECAST, HUMIDITY, MESSAGE, @@ -22,6 +23,7 @@ from buienradar.constants import ( TEMPERATURE, VISIBILITY, WINDAZIMUTH, + WINDGUST, WINDSPEED, ) from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url @@ -200,6 +202,14 @@ class BrData: except (ValueError, TypeError): return None + @property + def feeltemperature(self): + """Return the feeltemperature, or None.""" + try: + return float(self.data.get(FEELTEMPERATURE)) + except (ValueError, TypeError): + return None + @property def pressure(self): """Return the pressure, or None.""" @@ -224,6 +234,14 @@ class BrData: except (ValueError, TypeError): return None + @property + def wind_gust(self): + """Return the windgust, or None.""" + try: + return float(self.data.get(WINDGUST)) + except (ValueError, TypeError): + return None + @property def wind_speed(self): """Return the windspeed, or None.""" diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index 4b71024c241..568926ef0cd 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -9,6 +9,7 @@ from buienradar.constants import ( MAX_TEMP, MIN_TEMP, RAIN, + RAIN_CHANCE, WINDAZIMUTH, WINDSPEED, ) @@ -33,6 +34,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP_LOW, ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, Forecast, @@ -153,7 +155,9 @@ class BrWeather(WeatherEntity): ) self._attr_native_pressure = data.pressure self._attr_native_temperature = data.temperature + self._attr_native_apparent_temperature = data.feeltemperature self._attr_native_visibility = data.visibility + self._attr_native_wind_gust_speed = data.wind_gust self._attr_native_wind_speed = data.wind_speed self._attr_wind_bearing = data.wind_bearing @@ -188,6 +192,7 @@ class BrWeather(WeatherEntity): ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.get(MIN_TEMP), ATTR_FORECAST_NATIVE_TEMP: data_in.get(MAX_TEMP), ATTR_FORECAST_NATIVE_PRECIPITATION: data_in.get(RAIN), + ATTR_FORECAST_PRECIPITATION_PROBABILITY: data_in.get(RAIN_CHANCE), ATTR_FORECAST_WIND_BEARING: data_in.get(WINDAZIMUTH), ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.get(WINDSPEED), } diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index c0127c20d05..6612ea5209d 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -74,7 +74,7 @@ }, "get_events": { "name": "Get events", - "description": "Get events on a calendar within a time range.", + "description": "Retrieves events on a calendar within a time range.", "fields": { "start_date_time": { "name": "Start time", diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py index d18898fa916..5322ae7d9a2 100644 --- a/homeassistant/components/cambridge_audio/media_player.py +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -142,6 +142,12 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): @property def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" + if ( + not self.client.play_state.metadata.artist + and self.client.state.source == "IR" + ): + # Return channel instead of artist when playing internet radio + return self.client.play_state.metadata.station return self.client.play_state.metadata.artist @property @@ -169,6 +175,11 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): """Last time the media position was updated.""" return self.client.position_last_updated + @property + def media_channel(self) -> str | None: + """Channel currently playing.""" + return self.client.play_state.metadata.station + @property def is_volume_muted(self) -> bool | None: """Volume mute status.""" diff --git a/homeassistant/components/camera/img_util.py b/homeassistant/components/camera/img_util.py index bbe85bf82db..971e6804add 100644 --- a/homeassistant/components/camera/img_util.py +++ b/homeassistant/components/camera/img_util.py @@ -2,17 +2,10 @@ from __future__ import annotations -from contextlib import suppress import logging from typing import TYPE_CHECKING, Literal, cast -with suppress(Exception): - # TurboJPEG imports numpy which may or may not work so - # we have to guard the import here. We still want - # to import it at top level so it gets loaded - # in the import executor and not in the event loop. - from turbojpeg import TurboJPEG - +from turbojpeg import TurboJPEG if TYPE_CHECKING: from . import Image diff --git a/homeassistant/components/canary/__init__.py b/homeassistant/components/canary/__init__.py index b0e59e49a6f..4ea1bf48cf0 100644 --- a/homeassistant/components/canary/__init__.py +++ b/homeassistant/components/canary/__init__.py @@ -8,46 +8,18 @@ from typing import Final from canary.api import Api from requests.exceptions import ConnectTimeout, HTTPError -import voluptuous as vol -from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, Platform 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 ( - CONF_FFMPEG_ARGUMENTS, - DEFAULT_FFMPEG_ARGUMENTS, - DEFAULT_TIMEOUT, - DOMAIN, -) +from .const import CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator _LOGGER: Final = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES: Final = timedelta(seconds=30) -CONFIG_SCHEMA: Final = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional( - CONF_TIMEOUT, default=DEFAULT_TIMEOUT - ): cv.positive_int, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - PLATFORMS: Final[list[Platform]] = [ Platform.ALARM_CONTROL_PANEL, Platform.CAMERA, @@ -55,37 +27,6 @@ PLATFORMS: Final[list[Platform]] = [ ] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Canary integration.""" - if hass.config_entries.async_entries(DOMAIN): - return True - - ffmpeg_arguments = DEFAULT_FFMPEG_ARGUMENTS - if CAMERA_DOMAIN in config: - camera_config = next( - (item for item in config[CAMERA_DOMAIN] if item["platform"] == DOMAIN), - None, - ) - - if camera_config: - ffmpeg_arguments = camera_config.get( - CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS - ) - - if DOMAIN in config: - if ffmpeg_arguments != DEFAULT_FFMPEG_ARGUMENTS: - config[DOMAIN][CONF_FFMPEG_ARGUMENTS] = ffmpeg_arguments - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - return True - - async def async_setup_entry(hass: HomeAssistant, entry: CanaryConfigEntry) -> bool: """Set up Canary from a config entry.""" if not entry.options: diff --git a/homeassistant/components/canary/config_flow.py b/homeassistant/components/canary/config_flow.py index 17e660e96ac..390f65904fe 100644 --- a/homeassistant/components/canary/config_flow.py +++ b/homeassistant/components/canary/config_flow.py @@ -54,10 +54,6 @@ class CanaryConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return CanaryOptionsFlowHandler() - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Handle a flow initiated by configuration file.""" - return await self.async_step_user(import_data) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 6d8b2c5449d..250b2a67efe 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -28,10 +28,10 @@ "name": "Thermostat", "state": { "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", "heat": "Heat", "cool": "Cool", "heat_cool": "Heat/Cool", - "auto": "Auto", "dry": "Dry", "fan_only": "Fan only" }, @@ -50,10 +50,10 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", - "auto": "Auto", - "low": "Low", - "medium": "Medium", - "high": "High", + "auto": "[%key:common::state::auto%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "top": "Top", "middle": "Middle", "focus": "Focus", @@ -69,13 +69,13 @@ "hvac_action": { "name": "Current action", "state": { + "off": "[%key:common::state::off%]", + "idle": "[%key:common::state::idle%]", "cooling": "Cooling", "defrosting": "Defrosting", "drying": "Drying", "fan": "Fan", "heating": "Heating", - "idle": "[%key:common::state::idle%]", - "off": "[%key:common::state::off%]", "preheating": "Preheating" } }, @@ -98,13 +98,13 @@ "name": "Preset", "state": { "none": "None", - "eco": "Eco", - "away": "Away", + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]", + "activity": "Activity", "boost": "Boost", "comfort": "Comfort", - "home": "[%key:common::state::home%]", - "sleep": "Sleep", - "activity": "Activity" + "eco": "Eco", + "sleep": "Sleep" } }, "preset_modes": { @@ -257,8 +257,8 @@ "selector": { "hvac_mode": { "options": { - "off": "Off", - "auto": "Auto", + "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", "cool": "Cool", "dry": "Dry", "fan_only": "Fan only", diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index e0c15c74cab..9a977d2a5b9 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -93,3 +93,5 @@ STT_ENTITY_UNIQUE_ID = "cloud-speech-to-text" TTS_ENTITY_UNIQUE_ID = "cloud-text-to-speech" LOGIN_MFA_TIMEOUT = 60 + +VOICE_STYLE_SEPERATOR = "||" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 6f18cc424cd..7c7cb925e4f 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -18,7 +18,7 @@ from aiohttp import web import attr from hass_nabucasa import AlreadyConnectedError, Cloud, auth, thingtalk from hass_nabucasa.const import STATE_DISCONNECTED -from hass_nabucasa.voice import TTS_VOICES +from hass_nabucasa.voice_data import TTS_VOICES import voluptuous as vol from homeassistant.components import websocket_api @@ -57,6 +57,7 @@ from .const import ( PREF_REMOTE_ALLOW_REMOTE_ENABLE, PREF_TTS_DEFAULT_VOICE, REQUEST_TIMEOUT, + VOICE_STYLE_SEPERATOR, ) from .google_config import CLOUD_GOOGLE from .repairs import async_manage_legacy_subscription_issue @@ -591,10 +592,21 @@ async def websocket_subscription( def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]: """Validate language and voice.""" language, voice = value + style: str | None + voice, _, style = voice.partition(VOICE_STYLE_SEPERATOR) + if not style: + style = None if language not in TTS_VOICES: raise vol.Invalid(f"Invalid language {language}") - if voice not in TTS_VOICES[language]: + if voice not in (language_info := TTS_VOICES[language]): raise vol.Invalid(f"Invalid voice {voice} for language {language}") + voice_info = language_info[voice] + if style and ( + isinstance(voice_info, str) or style not in voice_info.get("variants", []) + ): + raise vol.Invalid( + f"Invalid style {style} for voice {voice} in language {language}" + ) return value @@ -1012,13 +1024,24 @@ def tts_info( msg: dict[str, Any], ) -> None: """Fetch available tts info.""" - connection.send_result( - msg["id"], - { - "languages": [ - (language, voice) - for language, voices in TTS_VOICES.items() - for voice in voices - ] - }, - ) + result = [] + for language, voices in TTS_VOICES.items(): + for voice_id, voice_info in voices.items(): + if isinstance(voice_info, str): + result.append((language, voice_id, voice_info)) + continue + + name = voice_info["name"] + result.append((language, voice_id, name)) + result.extend( + [ + ( + language, + f"{voice_id}{VOICE_STYLE_SEPERATOR}{variant}", + f"{name} ({variant})", + ) + for variant in voice_info.get("variants", []) + ] + ) + + connection.send_result(msg["id"], {"languages": result}) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 7f448f2f614..30e3925a591 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": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.94.0"], + "requirements": ["hass-nabucasa==0.96.0"], "single_config_entry": true } diff --git a/homeassistant/components/cloud/onboarding.py b/homeassistant/components/cloud/onboarding.py new file mode 100644 index 00000000000..ab0a0fbe310 --- /dev/null +++ b/homeassistant/components/cloud/onboarding.py @@ -0,0 +1,110 @@ +"""Cloud onboarding views.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from functools import wraps +from typing import TYPE_CHECKING, Any, Concatenate + +from aiohttp import web +from aiohttp.web_exceptions import HTTPUnauthorized + +from homeassistant.components.http import KEY_HASS +from homeassistant.components.onboarding import ( + BaseOnboardingView, + NoAuthBaseOnboardingView, +) +from homeassistant.core import HomeAssistant + +from . import http_api as cloud_http +from .const import DATA_CLOUD + +if TYPE_CHECKING: + from homeassistant.components.onboarding import OnboardingStoreData + + +async def async_setup_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: + """Set up the cloud views.""" + + hass.http.register_view(CloudForgotPasswordView(data)) + hass.http.register_view(CloudLoginView(data)) + hass.http.register_view(CloudLogoutView(data)) + hass.http.register_view(CloudStatusView(data)) + + +def ensure_not_done[_ViewT: BaseOnboardingView, **_P]( + func: Callable[ + Concatenate[_ViewT, web.Request, _P], + Coroutine[Any, Any, web.Response], + ], +) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]: + """Home Assistant API decorator to check onboarding and cloud.""" + + @wraps(func) + async def _ensure_not_done( + self: _ViewT, + request: web.Request, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> web.Response: + """Check onboarding status, cloud and call function.""" + if self._data["done"]: + # If at least one onboarding step is done, we don't allow accessing + # the cloud onboarding views. + raise HTTPUnauthorized + + return await func(self, request, *args, **kwargs) + + return _ensure_not_done + + +class CloudForgotPasswordView( + NoAuthBaseOnboardingView, cloud_http.CloudForgotPasswordView +): + """View to start Forgot Password flow.""" + + url = "/api/onboarding/cloud/forgot_password" + name = "api:onboarding:cloud:forgot_password" + + @ensure_not_done + async def post(self, request: web.Request) -> web.Response: + """Handle forgot password request.""" + return await super()._post(request) + + +class CloudLoginView(NoAuthBaseOnboardingView, cloud_http.CloudLoginView): + """Login to Home Assistant Cloud.""" + + url = "/api/onboarding/cloud/login" + name = "api:onboarding:cloud:login" + + @ensure_not_done + async def post(self, request: web.Request) -> web.Response: + """Handle login request.""" + return await super()._post(request) + + +class CloudLogoutView(NoAuthBaseOnboardingView, cloud_http.CloudLogoutView): + """Log out of the Home Assistant cloud.""" + + url = "/api/onboarding/cloud/logout" + name = "api:onboarding:cloud:logout" + + @ensure_not_done + async def post(self, request: web.Request) -> web.Response: + """Handle logout request.""" + return await super()._post(request) + + +class CloudStatusView(NoAuthBaseOnboardingView): + """Get cloud status view.""" + + url = "/api/onboarding/cloud/status" + name = "api:onboarding:cloud:status" + + @ensure_not_done + async def get(self, request: web.Request) -> web.Response: + """Return cloud status.""" + hass = request.app[KEY_HASS] + cloud = hass.data[DATA_CLOUD] + return self.json({"logged_in": cloud.is_logged_in}) diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index f901adfa99e..85ca599fa87 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -6,7 +6,8 @@ import logging from typing import Any from hass_nabucasa import Cloud -from hass_nabucasa.voice import MAP_VOICE, TTS_VOICES, AudioOutput, Gender, VoiceError +from hass_nabucasa.voice import MAP_VOICE, AudioOutput, Gender, VoiceError +from hass_nabucasa.voice_data import TTS_VOICES import voluptuous as vol from homeassistant.components.tts import ( @@ -30,7 +31,13 @@ from homeassistant.setup import async_when_setup from .assist_pipeline import async_migrate_cloud_pipeline_engine from .client import CloudClient -from .const import DATA_CLOUD, DATA_PLATFORMS_SETUP, DOMAIN, TTS_ENTITY_UNIQUE_ID +from .const import ( + DATA_CLOUD, + DATA_PLATFORMS_SETUP, + DOMAIN, + TTS_ENTITY_UNIQUE_ID, + VOICE_STYLE_SEPERATOR, +) from .prefs import CloudPreferences ATTR_GENDER = "gender" @@ -57,6 +64,7 @@ DEFAULT_VOICES = { "ar-SY": "AmanyNeural", "ar-TN": "ReemNeural", "ar-YE": "MaryamNeural", + "as-IN": "PriyomNeural", "az-AZ": "BabekNeural", "bg-BG": "KalinaNeural", "bn-BD": "NabanitaNeural", @@ -126,6 +134,8 @@ DEFAULT_VOICES = { "id-ID": "GadisNeural", "is-IS": "GudrunNeural", "it-IT": "ElsaNeural", + "iu-Cans-CA": "SiqiniqNeural", + "iu-Latn-CA": "SiqiniqNeural", "ja-JP": "NanamiNeural", "jv-ID": "SitiNeural", "ka-GE": "EkaNeural", @@ -147,6 +157,8 @@ DEFAULT_VOICES = { "ne-NP": "HemkalaNeural", "nl-BE": "DenaNeural", "nl-NL": "ColetteNeural", + "or-IN": "SubhasiniNeural", + "pa-IN": "OjasNeural", "pl-PL": "AgnieszkaNeural", "ps-AF": "LatifaNeural", "pt-BR": "FranciscaNeural", @@ -158,6 +170,7 @@ DEFAULT_VOICES = { "sl-SI": "PetraNeural", "so-SO": "UbaxNeural", "sq-AL": "AnilaNeural", + "sr-Latn-RS": "NicholasNeural", "sr-RS": "SophieNeural", "su-ID": "TutiNeural", "sv-SE": "SofieNeural", @@ -177,12 +190,9 @@ DEFAULT_VOICES = { "vi-VN": "HoaiMyNeural", "wuu-CN": "XiaotongNeural", "yue-CN": "XiaoMinNeural", - "zh-CN": "XiaoxiaoNeural", "zh-CN-henan": "YundengNeural", - "zh-CN-liaoning": "XiaobeiNeural", - "zh-CN-shaanxi": "XiaoniNeural", "zh-CN-shandong": "YunxiangNeural", - "zh-CN-sichuan": "YunxiNeural", + "zh-CN": "XiaoxiaoNeural", "zh-HK": "HiuMaanNeural", "zh-TW": "HsiaoChenNeural", "zu-ZA": "ThandoNeural", @@ -191,6 +201,39 @@ DEFAULT_VOICES = { _LOGGER = logging.getLogger(__name__) +@callback +def _prepare_voice_args( + *, + hass: HomeAssistant, + language: str, + voice: str, + gender: str | None, +) -> dict: + """Prepare voice arguments.""" + gender = handle_deprecated_gender(hass, gender) + style: str | None + original_voice, _, style = voice.partition(VOICE_STYLE_SEPERATOR) + if not style: + style = None + updated_voice = handle_deprecated_voice(hass, original_voice) + if updated_voice not in TTS_VOICES[language]: + default_voice = DEFAULT_VOICES[language] + _LOGGER.debug( + "Unsupported voice %s detected, falling back to default %s for %s", + voice, + default_voice, + language, + ) + updated_voice = default_voice + + return { + "language": language, + "voice": updated_voice, + "gender": gender, + "style": style, + } + + def _deprecated_platform(value: str) -> str: """Validate if platform is deprecated.""" if value == DOMAIN: @@ -328,36 +371,61 @@ class CloudTTSEntity(TextToSpeechEntity): """Return a list of supported voices for a language.""" if not (voices := TTS_VOICES.get(language)): return None - return [Voice(voice, voice) for voice in voices] + + result = [] + + for voice_id, voice_info in voices.items(): + if isinstance(voice_info, str): + result.append( + Voice( + voice_id, + voice_info, + ) + ) + continue + + name = voice_info["name"] + + result.append( + Voice( + voice_id, + name, + ) + ) + result.extend( + [ + Voice( + f"{voice_id}{VOICE_STYLE_SEPERATOR}{variant}", + f"{name} ({variant})", + ) + for variant in voice_info.get("variants", []) + ] + ) + + return result async def async_get_tts_audio( self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load TTS from Home Assistant Cloud.""" - gender: Gender | str | None = options.get(ATTR_GENDER) - gender = handle_deprecated_gender(self.hass, gender) - original_voice: str = options.get( - ATTR_VOICE, - self._voice if language == self._language else DEFAULT_VOICES[language], - ) - voice = handle_deprecated_voice(self.hass, original_voice) - if voice not in TTS_VOICES[language]: - default_voice = DEFAULT_VOICES[language] - _LOGGER.debug( - "Unsupported voice %s detected, falling back to default %s for %s", - voice, - default_voice, - language, - ) - voice = default_voice # Process TTS try: data = await self.cloud.voice.process_tts( text=message, - language=language, - gender=gender, - voice=voice, output=options[ATTR_AUDIO_OUTPUT], + **_prepare_voice_args( + hass=self.hass, + language=language, + voice=options.get( + ATTR_VOICE, + ( + self._voice + if language == self._language + else DEFAULT_VOICES[language] + ), + ), + gender=options.get(ATTR_GENDER), + ), ) except VoiceError as err: _LOGGER.error("Voice error: %s", err) @@ -369,6 +437,8 @@ class CloudTTSEntity(TextToSpeechEntity): class CloudProvider(Provider): """Home Assistant Cloud speech API provider.""" + has_entity = True + def __init__(self, cloud: Cloud[CloudClient]) -> None: """Initialize cloud provider.""" self.cloud = cloud @@ -401,7 +471,38 @@ class CloudProvider(Provider): """Return a list of supported voices for a language.""" if not (voices := TTS_VOICES.get(language)): return None - return [Voice(voice, voice) for voice in voices] + + result = [] + + for voice_id, voice_info in voices.items(): + if isinstance(voice_info, str): + result.append( + Voice( + voice_id, + voice_info, + ) + ) + continue + + name = voice_info["name"] + + result.append( + Voice( + voice_id, + name, + ) + ) + result.extend( + [ + Voice( + f"{voice_id}{VOICE_STYLE_SEPERATOR}{variant}", + f"{name} ({variant})", + ) + for variant in voice_info.get("variants", []) + ] + ) + + return result @property def default_options(self) -> dict[str, str]: @@ -415,30 +516,22 @@ class CloudProvider(Provider): ) -> TtsAudioType: """Load TTS from Home Assistant Cloud.""" assert self.hass is not None - gender: Gender | str | None = options.get(ATTR_GENDER) - gender = handle_deprecated_gender(self.hass, gender) - original_voice: str = options.get( - ATTR_VOICE, - self._voice if language == self._language else DEFAULT_VOICES[language], - ) - voice = handle_deprecated_voice(self.hass, original_voice) - if voice not in TTS_VOICES[language]: - default_voice = DEFAULT_VOICES[language] - _LOGGER.debug( - "Unsupported voice %s detected, falling back to default %s for %s", - voice, - default_voice, - language, - ) - voice = default_voice # Process TTS try: data = await self.cloud.voice.process_tts( text=message, - language=language, - gender=gender, - voice=voice, output=options[ATTR_AUDIO_OUTPUT], + **_prepare_voice_args( + hass=self.hass, + language=language, + voice=options.get( + ATTR_VOICE, + self._voice + if language == self._language + else DEFAULT_VOICES[language], + ), + gender=options.get(ATTR_GENDER), + ), ) except VoiceError as err: _LOGGER.error("Voice error: %s", err) diff --git a/homeassistant/components/cloudflare/config_flow.py b/homeassistant/components/cloudflare/config_flow.py index c3845a447e4..1fad38c5afc 100644 --- a/homeassistant/components/cloudflare/config_flow.py +++ b/homeassistant/components/cloudflare/config_flow.py @@ -9,7 +9,6 @@ from typing import Any import pycfdns import voluptuous as vol -from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_TOKEN, CONF_ZONE from homeassistant.core import HomeAssistant @@ -118,8 +117,6 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" - persistent_notification.async_dismiss(self.hass, "cloudflare_setup") - errors: dict[str, str] = {} if user_input is not None: diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index 60a4e40140d..c2a7498afec 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -12,6 +12,7 @@ from .coordinator import ( ComelitSerialBridge, ComelitVedoSystem, ) +from .utils import async_client_session BRIDGE_PLATFORMS = [ Platform.CLIMATE, @@ -32,6 +33,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> b """Set up Comelit platform.""" coordinator: ComelitBaseCoordinator + + session = await async_client_session(hass) + if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE: coordinator = ComelitSerialBridge( hass, @@ -39,6 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> b entry.data[CONF_HOST], entry.data.get(CONF_PORT, DEFAULT_PORT), entry.data[CONF_PIN], + session, ) platforms = BRIDGE_PLATFORMS else: @@ -48,6 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> b entry.data[CONF_HOST], entry.data.get(CONF_PORT, DEFAULT_PORT), entry.data[CONF_PIN], + session, ) platforms = VEDO_PLATFORMS diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py index 1ad26905dd1..53e767b4434 100644 --- a/homeassistant/components/comelit/alarm_control_panel.py +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -83,7 +83,6 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel config_entry_entry_id: str, ) -> None: """Initialize the alarm panel.""" - self._api = coordinator.api self._area_index = area.index super().__init__(coordinator) # Use config_entry.entry_id as base for unique_id @@ -137,30 +136,38 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if code != str(self._api.device_pin): + if code != str(self.coordinator.api.device_pin): return - await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[DISABLE]) + await self.coordinator.api.set_zone_status( + self._area.index, ALARM_ACTIONS[DISABLE] + ) await self._async_update_state( AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE] ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[AWAY]) + await self.coordinator.api.set_zone_status( + self._area.index, ALARM_ACTIONS[AWAY] + ) await self._async_update_state( AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY] ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[HOME]) + await self.coordinator.api.set_zone_status( + self._area.index, ALARM_ACTIONS[HOME] + ) await self._async_update_state( AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1] ) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[NIGHT]) + await self.coordinator.api.set_zone_status( + self._area.index, ALARM_ACTIONS[NIGHT] + ) await self._async_update_state( AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT] ) diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py index dfa6d3e97f3..e1be330afae 100644 --- a/homeassistant/components/comelit/binary_sensor.py +++ b/homeassistant/components/comelit/binary_sensor.py @@ -50,7 +50,6 @@ class ComelitVedoBinarySensorEntity( config_entry_entry_id: str, ) -> None: """Init sensor entity.""" - self._api = coordinator.api self._zone_index = zone.index super().__init__(coordinator) # Use config_entry.entry_id as base for unique_id diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 3ec79001d55..be5b892e53c 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -19,10 +19,10 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import ComelitConfigEntry, ComelitSerialBridge +from .entity import ComelitBridgeBaseEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -89,7 +89,7 @@ async def async_setup_entry( ) -class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity): +class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity): """Climate device.""" _attr_hvac_modes = [HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF] @@ -102,7 +102,6 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity ) _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_has_entity_name = True _attr_name = None def __init__( @@ -112,13 +111,7 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity config_entry_entry_id: str, ) -> None: """Init light entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available - self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device, device.type) + super().__init__(coordinator, device, config_entry_entry_id) self._update_attributes() def _update_attributes(self) -> None: diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index 5854bc1e324..f6bda97a781 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -22,6 +22,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN +from .utils import async_client_session DEFAULT_HOST = "192.168.1.252" DEFAULT_PIN = 111111 @@ -47,10 +48,14 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, """Validate the user input allows us to connect.""" api: ComelitCommonApi + + session = await async_client_session(hass) if data.get(CONF_TYPE, BRIDGE) == BRIDGE: - api = ComeliteSerialBridgeApi(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) + api = ComeliteSerialBridgeApi( + data[CONF_HOST], data[CONF_PORT], data[CONF_PIN], session + ) else: - api = ComelitVedoApi(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) + api = ComelitVedoApi(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN], session) try: await api.login() diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index df4965d9945..a5a90c07568 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -15,6 +15,7 @@ from aiocomelit.api import ( ) from aiocomelit.const import BRIDGE, VEDO from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData +from aiohttp import ClientSession from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -95,9 +96,16 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]): await self.api.login() return await self._async_update_system_data() except (CannotConnect, CannotRetrieveData) as err: - raise UpdateFailed(repr(err)) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": repr(err)}, + ) from err except CannotAuthenticate as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="cannot_authenticate", + ) from err @abstractmethod async def _async_update_system_data(self) -> T: @@ -119,9 +127,10 @@ class ComelitSerialBridge( host: str, port: int, pin: int, + session: ClientSession, ) -> None: """Initialize the scanner.""" - self.api = ComeliteSerialBridgeApi(host, port, pin) + self.api = ComeliteSerialBridgeApi(host, port, pin, session) super().__init__(hass, entry, BRIDGE, host) async def _async_update_system_data( @@ -144,9 +153,10 @@ class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]): host: str, port: int, pin: int, + session: ClientSession, ) -> None: """Initialize the scanner.""" - self.api = ComelitVedoApi(host, port, pin) + self.api = ComelitVedoApi(host, port, pin, session) super().__init__(hass, entry, VEDO, host) async def _async_update_system_data( diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index befcb0c35d4..d430952fabf 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -11,9 +11,9 @@ from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge +from .entity import ComelitBridgeBaseEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -34,13 +34,10 @@ async def async_setup_entry( ) -class ComelitCoverEntity( - CoordinatorEntity[ComelitSerialBridge], RestoreEntity, CoverEntity -): +class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity): """Cover device.""" _attr_device_class = CoverDeviceClass.SHUTTER - _attr_has_entity_name = True _attr_name = None def __init__( @@ -50,13 +47,7 @@ class ComelitCoverEntity( config_entry_entry_id: str, ) -> None: """Init cover entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available - self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device, device.type) + super().__init__(coordinator, device, config_entry_entry_id) # Device doesn't provide a status so we assume UNKNOWN at first startup self._last_action: int | None = None self._last_state: str | None = None @@ -101,7 +92,7 @@ class ComelitCoverEntity( async def _cover_set_state(self, action: int, state: int) -> None: """Set desired cover state.""" self._last_state = self.state - await self._api.set_device_status(COVER, self._device.index, action) + await self.coordinator.api.set_device_status(COVER, self._device.index, action) self.coordinator.data[COVER][self._device.index].status = state self.async_write_ha_state() diff --git a/homeassistant/components/comelit/entity.py b/homeassistant/components/comelit/entity.py new file mode 100644 index 00000000000..409cd6a3f42 --- /dev/null +++ b/homeassistant/components/comelit/entity.py @@ -0,0 +1,29 @@ +"""Base entity for Comelit.""" + +from __future__ import annotations + +from aiocomelit import ComelitSerialBridgeObject + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import ComelitSerialBridge + + +class ComelitBridgeBaseEntity(CoordinatorEntity[ComelitSerialBridge]): + """Comelit Bridge base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: ComelitSerialBridge, + device: ComelitSerialBridgeObject, + config_entry_entry_id: str, + ) -> None: + """Init cover entity.""" + self._device = device + super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available + self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" + self._attr_device_info = coordinator.platform_device_info(device, device.type) diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index d7b20f731a9..816d5c6bb38 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -19,10 +19,10 @@ from homeassistant.components.humidifier import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import ComelitConfigEntry, ComelitSerialBridge +from .entity import ComelitBridgeBaseEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -92,14 +92,13 @@ async def async_setup_entry( async_add_entities(entities) -class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], HumidifierEntity): +class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity): """Humidifier device.""" _attr_supported_features = HumidifierEntityFeature.MODES _attr_available_modes = [MODE_NORMAL, MODE_AUTO] _attr_min_humidity = 10 _attr_max_humidity = 90 - _attr_has_entity_name = True def __init__( self, @@ -112,13 +111,8 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier device_class: HumidifierDeviceClass, ) -> None: """Init light entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available + super().__init__(coordinator, device, config_entry_entry_id) self._attr_unique_id = f"{config_entry_entry_id}-{device.index}-{device_class}" - self._attr_device_info = coordinator.platform_device_info(device, device_class) self._attr_device_class = device_class self._attr_translation_key = device_class.value self._active_mode = active_mode diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 53cf6bdcb46..27d9a8d57dd 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -4,15 +4,14 @@ from __future__ import annotations from typing import Any, cast -from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import LIGHT, STATE_OFF, STATE_ON from homeassistant.components.light import ColorMode, LightEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge +from .entity import ComelitBridgeBaseEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -33,29 +32,13 @@ async def async_setup_entry( ) -class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): +class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity): """Light device.""" _attr_color_mode = ColorMode.ONOFF - _attr_has_entity_name = True _attr_name = None _attr_supported_color_modes = {ColorMode.ONOFF} - def __init__( - self, - coordinator: ComelitSerialBridge, - device: ComelitSerialBridgeObject, - config_entry_entry_id: str, - ) -> None: - """Init light entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available - self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device, device.type) - async def _light_set_state(self, state: int) -> None: """Set desired light state.""" await self.coordinator.api.set_device_status(LIGHT, self._device.index, state) diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 3abfc222e7d..2097d1c25f6 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -7,5 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.11.3"] + "quality_scale": "bronze", + "requirements": ["aiocomelit==0.12.0"] } diff --git a/homeassistant/components/comelit/quality_scale.yaml b/homeassistant/components/comelit/quality_scale.yaml new file mode 100644 index 00000000000..b6d6cbc1046 --- /dev/null +++ b/homeassistant/components/comelit/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: no actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: no 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: todo + comment: wrap api calls in try block + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: no configuration parameters + 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: done + discovery-update-info: + status: exempt + comment: device not discoverable + discovery: + status: exempt + comment: device not discoverable + docs-data-update: done + docs-examples: done + docs-known-limitations: + status: exempt + comment: no known limitations, yet + docs-supported-devices: + status: todo + comment: review and complete missing ones + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: todo + comment: missing implementation + entity-category: + status: todo + comment: PR in progress + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: todo + comment: PR in progress + repair-issues: + status: exempt + comment: no known use cases for repair issues or flows, yet + stale-devices: + status: todo + comment: missing implementation + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index c93ccd30eb6..a11cac4e1c0 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -19,6 +19,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem +from .entity import ComelitBridgeBaseEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -95,10 +96,9 @@ async def async_setup_vedo_entry( async_add_entities(entities) -class ComelitBridgeSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity): +class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity): """Sensor device.""" - _attr_has_entity_name = True _attr_name = None def __init__( @@ -109,13 +109,7 @@ class ComelitBridgeSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEn description: SensorEntityDescription, ) -> None: """Init sensor entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available - self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device, device.type) + super().__init__(coordinator, device, config_entry_entry_id) self.entity_description = description @@ -144,7 +138,6 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity description: SensorEntityDescription, ) -> None: """Init sensor entity.""" - self._api = coordinator.api self._zone_index = zone.index super().__init__(coordinator) # Use config_entry.entry_id as base for unique_id diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 21f2b89db98..2076ecb5c1e 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -42,9 +42,9 @@ "sensor": { "zone_status": { "state": { + "open": "[%key:common::state::open%]", "alarm": "Alarm", "armed": "Armed", - "open": "Open", "excluded": "Excluded", "faulty": "Faulty", "inhibited": "Inhibited", @@ -74,7 +74,10 @@ "message": "Error connecting: {error}" }, "cannot_authenticate": { - "message": "Error authenticating: {error}" + "message": "Error authenticating" + }, + "updated_failed": { + "message": "Failed to update data: {error}" } } } diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index 98f0894ca30..658f37f70af 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -10,9 +10,9 @@ from aiocomelit.const import IRRIGATION, OTHER, STATE_OFF, STATE_ON from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge +from .entity import ComelitBridgeBaseEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -39,10 +39,9 @@ async def async_setup_entry( async_add_entities(entities) -class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): +class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity): """Switch device.""" - _attr_has_entity_name = True _attr_name = None def __init__( @@ -52,13 +51,8 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): config_entry_entry_id: str, ) -> None: """Init switch entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available + super().__init__(coordinator, device, config_entry_entry_id) self._attr_unique_id = f"{config_entry_entry_id}-{device.type}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device, device.type) if device.type == OTHER: self._attr_device_class = SwitchDeviceClass.OUTLET diff --git a/homeassistant/components/comelit/utils.py b/homeassistant/components/comelit/utils.py new file mode 100644 index 00000000000..fe05e2412b0 --- /dev/null +++ b/homeassistant/components/comelit/utils.py @@ -0,0 +1,13 @@ +"""Utils for Comelit.""" + +from aiohttp import ClientSession, CookieJar + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + + +async def async_client_session(hass: HomeAssistant) -> ClientSession: + """Return a new aiohttp session.""" + return aiohttp_client.async_create_clientsession( + hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) + ) diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index 1832e83e7dd..b74c79fd842 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -56,7 +56,10 @@ from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service -from homeassistant.helpers.trigger_template_entity import CONF_AVAILABILITY +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + ValueTemplate, +) from homeassistant.helpers.typing import ConfigType from .const import ( @@ -91,7 +94,9 @@ BINARY_SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional( @@ -108,7 +113,9 @@ COVER_SCHEMA = vol.Schema( vol.Optional(CONF_COMMAND_STOP, default="true"): cv.string, vol.Required(CONF_NAME): cv.string, vol.Optional(CONF_ICON): cv.template, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_DEVICE_CLASS): COVER_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_UNIQUE_ID): cv.string, @@ -134,7 +141,9 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_NAME, default=SENSOR_DEFAULT_NAME): cv.string, vol.Optional(CONF_ICON): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, @@ -150,7 +159,9 @@ SWITCH_SCHEMA = vol.Schema( vol.Optional(CONF_COMMAND_ON, default="true"): cv.string, vol.Optional(CONF_COMMAND_STATE): cv.string, vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_ICON): cv.template, vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index fab56ae6887..727bf5b86ca 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -18,7 +18,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + ManualTriggerEntity, + ValueTemplate, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -50,7 +53,7 @@ async def async_setup_platform( scan_interval: timedelta = binary_sensor_config.get( CONF_SCAN_INTERVAL, SCAN_INTERVAL ) - value_template: Template | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE) + value_template: ValueTemplate | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE) data = CommandSensorData(hass, command, command_timeout) @@ -86,7 +89,7 @@ class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): config: ConfigType, payload_on: str, payload_off: str, - value_template: Template | None, + value_template: ValueTemplate | None, scan_interval: timedelta, ) -> None: """Initialize the Command line binary sensor.""" @@ -133,9 +136,14 @@ class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): await self.data.async_update() value = self.data.value + variables = self._template_variables_with_value(value) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return + if self._value_template is not None: - value = self._value_template.async_render_with_possible_json_value( - value, None + value = self._value_template.async_render_as_value_template( + self.entity_id, variables, None ) self._attr_is_on = None if value == self._payload_on: @@ -143,7 +151,7 @@ class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): elif value == self._payload_off: self._attr_is_on = False - self._process_manual_data(value) + self._process_manual_data(variables) self.async_write_ha_state() async def async_update(self) -> None: diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 7f1bc12264c..066f6ae0388 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -20,7 +20,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + ManualTriggerEntity, + ValueTemplate, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify @@ -79,7 +82,7 @@ class CommandCover(ManualTriggerEntity, CoverEntity): command_close: str, command_stop: str, command_state: str | None, - value_template: Template | None, + value_template: ValueTemplate | None, timeout: int, scan_interval: timedelta, ) -> None: @@ -164,14 +167,20 @@ class CommandCover(ManualTriggerEntity, CoverEntity): """Update device state.""" if self._command_state: payload = str(await self._async_query_state()) + + variables = self._template_variables_with_value(payload) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return + if self._value_template: - payload = self._value_template.async_render_with_possible_json_value( - payload, None + payload = self._value_template.async_render_as_value_template( + self.entity_id, variables, None ) self._state = None if payload: self._state = int(payload) - self._process_manual_data(payload) + self._process_manual_data(variables) self.async_write_ha_state() async def async_update(self) -> None: diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index b7c36a005fa..5ce50edc4e7 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -23,7 +23,10 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerSensorEntity +from homeassistant.helpers.trigger_template_entity import ( + ManualTriggerSensorEntity, + ValueTemplate, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -57,7 +60,7 @@ async def async_setup_platform( json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES) json_attributes_path: str | None = sensor_config.get(CONF_JSON_ATTRIBUTES_PATH) scan_interval: timedelta = sensor_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) - value_template: Template | None = sensor_config.get(CONF_VALUE_TEMPLATE) + value_template: ValueTemplate | None = sensor_config.get(CONF_VALUE_TEMPLATE) data = CommandSensorData(hass, command, command_timeout) trigger_entity_config = { @@ -88,7 +91,7 @@ class CommandSensor(ManualTriggerSensorEntity): self, data: CommandSensorData, config: ConfigType, - value_template: Template | None, + value_template: ValueTemplate | None, json_attributes: list[str] | None, json_attributes_path: str | None, scan_interval: timedelta, @@ -144,6 +147,11 @@ class CommandSensor(ManualTriggerSensorEntity): await self.data.async_update() value = self.data.value + variables = self._template_variables_with_value(self.data.value) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return + if self._json_attributes: self._attr_extra_state_attributes = {} if value: @@ -168,16 +176,17 @@ class CommandSensor(ManualTriggerSensorEntity): LOGGER.warning("Unable to parse output as JSON: %s", value) else: LOGGER.warning("Empty reply found when expecting JSON data") + if self._value_template is None: self._attr_native_value = None - self._process_manual_data(value) + self._process_manual_data(variables) + self.async_write_ha_state() return self._attr_native_value = None if self._value_template is not None and value is not None: - value = self._value_template.async_render_with_possible_json_value( - value, - None, + value = self._value_template.async_render_as_value_template( + self.entity_id, variables, None ) if self.device_class not in { @@ -190,7 +199,7 @@ class CommandSensor(ManualTriggerSensorEntity): value, self.entity_id, self.device_class ) - self._process_manual_data(value) + self._process_manual_data(variables) self.async_write_ha_state() async def async_update(self) -> None: diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 31400048ddc..9d6b84c105f 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -19,7 +19,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + ManualTriggerEntity, + ValueTemplate, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify @@ -78,7 +81,7 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): command_on: str, command_off: str, command_state: str | None, - value_template: Template | None, + value_template: ValueTemplate | None, timeout: int, scan_interval: timedelta, ) -> None: @@ -166,15 +169,21 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): """Update device state.""" if self._command_state: payload = str(await self._async_query_state()) + + variables = self._template_variables_with_value(payload) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return + value = None if self._value_template: - value = self._value_template.async_render_with_possible_json_value( - payload, None + value = self._value_template.async_render_as_value_template( + self.entity_id, variables, None ) self._attr_is_on = None if payload or value: self._attr_is_on = (value or payload).lower() == "true" - self._process_manual_data(payload) + self._process_manual_data(variables) self.async_write_ha_state() async def async_update(self) -> None: diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 74c9b5a9d0c..6e2d4a5da49 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -58,7 +58,8 @@ def async_setup(hass: HomeAssistant) -> bool: websocket_api.async_register_command(hass, config_entry_get_single) websocket_api.async_register_command(hass, config_entry_update) websocket_api.async_register_command(hass, config_entries_subscribe) - websocket_api.async_register_command(hass, config_entries_progress) + websocket_api.async_register_command(hass, config_entries_flow_progress) + websocket_api.async_register_command(hass, config_entries_flow_subscribe) websocket_api.async_register_command(hass, ignore_config_flow) websocket_api.async_register_command(hass, config_subentry_delete) @@ -357,7 +358,7 @@ class SubentryManagerFlowResourceView( @websocket_api.require_admin @websocket_api.websocket_command({"type": "config_entries/flow/progress"}) -def config_entries_progress( +def config_entries_flow_progress( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], @@ -378,6 +379,66 @@ def config_entries_progress( ) +@websocket_api.require_admin +@websocket_api.websocket_command({"type": "config_entries/flow/subscribe"}) +def config_entries_flow_subscribe( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Subscribe to non user created flows being initiated or removed. + + When initiating the subscription, the current flows are sent to the client. + + Example of a non-user initiated flow is a discovered Hue hub that + requires user interaction to finish setup. + """ + + @callback + def async_on_flow_init_remove(change_type: str, flow_id: str) -> None: + """Forward config entry state events to websocket.""" + if change_type == "removed": + connection.send_message( + websocket_api.event_message( + msg["id"], + [{"type": change_type, "flow_id": flow_id}], + ) + ) + return + # change_type == "added" + connection.send_message( + websocket_api.event_message( + msg["id"], + [ + { + "type": change_type, + "flow_id": flow_id, + "flow": hass.config_entries.flow.async_get(flow_id), + } + ], + ) + ) + + connection.subscriptions[msg["id"]] = hass.config_entries.flow.async_subscribe_flow( + async_on_flow_init_remove + ) + connection.send_message( + websocket_api.event_message( + msg["id"], + [ + {"type": None, "flow_id": flw["flow_id"], "flow": flw} + for flw in hass.config_entries.flow.async_progress() + if flw["context"]["source"] + not in ( + config_entries.SOURCE_RECONFIGURE, + config_entries.SOURCE_USER, + ) + ], + ) + ) + connection.send_result(msg["id"]) + + def send_entry_not_found( connection: websocket_api.ActiveConnection, msg_id: int ) -> None: diff --git a/homeassistant/components/constructa/__init__.py b/homeassistant/components/constructa/__init__.py new file mode 100644 index 00000000000..1b3870860a0 --- /dev/null +++ b/homeassistant/components/constructa/__init__.py @@ -0,0 +1 @@ +"""Constructa virtual integration.""" diff --git a/homeassistant/components/constructa/manifest.json b/homeassistant/components/constructa/manifest.json new file mode 100644 index 00000000000..7b73f2e2ed0 --- /dev/null +++ b/homeassistant/components/constructa/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "constructa", + "name": "Constructa", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index cb7b8dd22f7..c78f41f3c5c 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -197,6 +197,7 @@ class ChatLog: ( "?", ";", # Greek question mark + "?", # Chinese question mark ) ) ) @@ -354,11 +355,40 @@ class ChatLog: if self.delta_listener: self.delta_listener(self, asdict(tool_result)) + async def _async_expand_prompt_template( + self, + llm_context: llm.LLMContext, + prompt: str, + language: str, + user_name: str | None = None, + ) -> str: + try: + return template.Template(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=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 + async def async_update_llm_data( self, conversing_domain: str, user_input: ConversationInput, - user_llm_hass_api: str | None = None, + user_llm_hass_api: str | list[str] | None = None, user_llm_prompt: str | None = None, ) -> None: """Set the LLM system prompt.""" @@ -409,38 +439,28 @@ class ChatLog: ): 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", + prompt_parts = [] + prompt_parts.append( + await self._async_expand_prompt_template( + llm_context, + (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT), + user_input.language, + user_name, ) - 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) + prompt_parts.append( + await self._async_expand_prompt_template( + llm_context, + llm.BASE_PROMPT, + user_input.language, + user_name, + ) + ) + if extra_system_prompt := ( # Take new system prompt if one was given user_input.extra_system_prompt or self.extra_system_prompt diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index a1281764bd5..2955bb96833 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.28"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.5.7"] } diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json index ae384fb6635..52f99133546 100644 --- a/homeassistant/components/cookidoo/strings.json +++ b/homeassistant/components/cookidoo/strings.json @@ -6,7 +6,7 @@ "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]", - "country": "Country" + "country": "[%key:common::config_flow::data::country%]" }, "data_description": { "email": "Email used to access your {cookidoo} account.", diff --git a/homeassistant/components/cover/reproduce_state.py b/homeassistant/components/cover/reproduce_state.py index de3e0cebfb7..927e725460c 100644 --- a/homeassistant/components/cover/reproduce_state.py +++ b/homeassistant/components/cover/reproduce_state.py @@ -73,14 +73,14 @@ async def _async_set_position( Returns True if the position was set, False if there is no supported method for setting the position. """ - if target_position == FULL_CLOSE and CoverEntityFeature.CLOSE in features: - await service_call(SERVICE_CLOSE_COVER, service_data) - elif target_position == FULL_OPEN and CoverEntityFeature.OPEN in features: - await service_call(SERVICE_OPEN_COVER, service_data) - elif CoverEntityFeature.SET_POSITION in features: + if CoverEntityFeature.SET_POSITION in features: await service_call( SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: target_position} ) + elif target_position == FULL_CLOSE and CoverEntityFeature.CLOSE in features: + await service_call(SERVICE_CLOSE_COVER, service_data) + elif target_position == FULL_OPEN and CoverEntityFeature.OPEN in features: + await service_call(SERVICE_OPEN_COVER, service_data) else: # Requested a position but the cover doesn't support it return False @@ -98,15 +98,17 @@ async def _async_set_tilt_position( Returns True if the tilt position was set, False if there is no supported method for setting the tilt position. """ - if target_tilt_position == FULL_CLOSE and CoverEntityFeature.CLOSE_TILT in features: - await service_call(SERVICE_CLOSE_COVER_TILT, service_data) - elif target_tilt_position == FULL_OPEN and CoverEntityFeature.OPEN_TILT in features: - await service_call(SERVICE_OPEN_COVER_TILT, service_data) - elif CoverEntityFeature.SET_TILT_POSITION in features: + if CoverEntityFeature.SET_TILT_POSITION in features: await service_call( SERVICE_SET_COVER_TILT_POSITION, service_data | {ATTR_TILT_POSITION: target_tilt_position}, ) + elif ( + target_tilt_position == FULL_CLOSE and CoverEntityFeature.CLOSE_TILT in features + ): + await service_call(SERVICE_CLOSE_COVER_TILT, service_data) + elif target_tilt_position == FULL_OPEN and CoverEntityFeature.OPEN_TILT in features: + await service_call(SERVICE_OPEN_COVER_TILT, service_data) else: # Requested a tilt position but the cover doesn't support it return False @@ -183,12 +185,12 @@ async def _async_reproduce_state( current_attrs = cur_state.attributes target_attrs = state.attributes - current_position = current_attrs.get(ATTR_CURRENT_POSITION) - target_position = target_attrs.get(ATTR_CURRENT_POSITION) + current_position: int | None = current_attrs.get(ATTR_CURRENT_POSITION) + target_position: int | None = target_attrs.get(ATTR_CURRENT_POSITION) position_matches = current_position == target_position - current_tilt_position = current_attrs.get(ATTR_CURRENT_TILT_POSITION) - target_tilt_position = target_attrs.get(ATTR_CURRENT_TILT_POSITION) + current_tilt_position: int | None = current_attrs.get(ATTR_CURRENT_TILT_POSITION) + target_tilt_position: int | None = target_attrs.get(ATTR_CURRENT_TILT_POSITION) tilt_position_matches = current_tilt_position == target_tilt_position state_matches = cur_state.state == target_state @@ -214,19 +216,11 @@ async def _async_reproduce_state( ) service_data = {ATTR_ENTITY_ID: entity_id} - set_position = ( - not position_matches - and target_position is not None - and await _async_set_position( - service_call, service_data, features, target_position - ) + set_position = target_position is not None and await _async_set_position( + service_call, service_data, features, target_position ) - set_tilt = ( - not tilt_position_matches - and target_tilt_position is not None - and await _async_set_tilt_position( - service_call, service_data, features, target_tilt_position - ) + set_tilt = target_tilt_position is not None and await _async_set_tilt_position( + service_call, service_data, features, target_tilt_position ) if target_state in CLOSING_STATES: diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index 0afef8a200f..6ca8b50620f 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -38,10 +38,10 @@ "name": "[%key:component::cover::title%]", "state": { "open": "[%key:common::state::open%]", - "opening": "Opening", + "opening": "[%key:common::state::opening%]", "closed": "[%key:common::state::closed%]", - "closing": "Closing", - "stopped": "Stopped" + "closing": "[%key:common::state::closing%]", + "stopped": "[%key:common::state::stopped%]" }, "state_attributes": { "current_position": { diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index 52059aa8785..a64bdd5050e 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -73,7 +73,7 @@ "remote_moved_any_side": "Device moved with any side up", "remote_double_tap_any_side": "Device double tapped on any side", "remote_turned_clockwise": "Device turned clockwise", - "remote_turned_counter_clockwise": "Device turned counter clockwise", + "remote_turned_counter_clockwise": "Device turned counterclockwise", "remote_rotate_from_side_1": "Device rotated from \"side 1\" to \"{subtype}\"", "remote_rotate_from_side_2": "Device rotated from \"side 2\" to \"{subtype}\"", "remote_rotate_from_side_3": "Device rotated from \"side 3\" to \"{subtype}\"", diff --git a/homeassistant/components/deluge/strings.json b/homeassistant/components/deluge/strings.json index 6adde8ef7df..ddea78b315f 100644 --- a/homeassistant/components/deluge/strings.json +++ b/homeassistant/components/deluge/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "To be able to use this integration, you have to enable the following option in deluge settings: Daemon > Allow remote controls", + "description": "To be able to use this integration, you have to enable the following option in Deluge settings: Daemon > Allow remote controls", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", diff --git a/homeassistant/components/demo/icons.json b/homeassistant/components/demo/icons.json index eafcbb9161a..9a076f47a2d 100644 --- a/homeassistant/components/demo/icons.json +++ b/homeassistant/components/demo/icons.json @@ -45,6 +45,17 @@ } } }, + "light": { + "bed_light": { + "state_attributes": { + "effect": { + "state": { + "rainbow": "mdi:looks" + } + } + } + } + }, "number": { "volume": { "default": "mdi:volume-high" diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py index c00f2b42828..25a7b46bfb6 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -15,6 +15,7 @@ from homeassistant.components.light import ( ATTR_WHITE, DEFAULT_MAX_KELVIN, DEFAULT_MIN_KELVIN, + EFFECT_OFF, ColorMode, LightEntity, LightEntityFeature, @@ -28,7 +29,7 @@ from . import DOMAIN LIGHT_COLORS = [(56, 86), (345, 75)] -LIGHT_EFFECT_LIST = ["rainbow", "none"] +LIGHT_EFFECT_LIST = ["rainbow", EFFECT_OFF] LIGHT_TEMPS = [4166, 2631] @@ -48,6 +49,7 @@ async def async_setup_entry( available=True, effect_list=LIGHT_EFFECT_LIST, effect=LIGHT_EFFECT_LIST[0], + translation_key="bed_light", device_name="Bed Light", state=False, unique_id="light_1", @@ -119,8 +121,10 @@ class DemoLight(LightEntity): rgbw_color: tuple[int, int, int, int] | None = None, rgbww_color: tuple[int, int, int, int, int] | None = None, supported_color_modes: set[ColorMode] | None = None, + translation_key: str | None = None, ) -> None: """Initialize the light.""" + self._attr_translation_key = translation_key self._available = True self._brightness = brightness self._ct = ct or random.choice(LIGHT_TEMPS) diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index de2a2cb3937..5cd83722742 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -41,6 +41,7 @@ async def async_setup_entry( DemoTVShowPlayer(), DemoBrowsePlayer("Browse"), DemoGroupPlayer("Group"), + DemoSearchPlayer("Search"), ] ) @@ -95,6 +96,8 @@ NETFLIX_PLAYER_SUPPORT = ( BROWSE_PLAYER_SUPPORT = MediaPlayerEntityFeature.BROWSE_MEDIA +SEARCH_PLAYER_SUPPORT = MediaPlayerEntityFeature.SEARCH_MEDIA + class AbstractDemoPlayer(MediaPlayerEntity): """A demo media players.""" @@ -398,3 +401,9 @@ class DemoGroupPlayer(AbstractDemoPlayer): | MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.TURN_OFF ) + + +class DemoSearchPlayer(AbstractDemoPlayer): + """A Demo media player that supports searching.""" + + _attr_supported_features = SEARCH_PLAYER_SUPPORT diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index da72b33d3ca..e22b4c413d5 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -28,10 +28,10 @@ "state_attributes": { "fan_mode": { "state": { - "auto_high": "Auto High", - "auto_low": "Auto Low", - "on_high": "On High", - "on_low": "On Low" + "auto_high": "Auto high", + "auto_low": "Auto low", + "on_high": "On high", + "on_low": "On low" } }, "swing_mode": { @@ -39,14 +39,14 @@ "1": "1", "2": "2", "3": "3", - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "off": "[%key:common::state::off%]" } }, "swing_horizontal_mode": { "state": { "rangefull": "Full range", - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "off": "[%key:common::state::off%]" } } @@ -58,7 +58,7 @@ "state_attributes": { "preset_mode": { "state": { - "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", + "auto": "[%key:common::state::auto%]", "sleep": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]", "smart": "Smart", "on": "[%key:common::state::on%]" @@ -78,12 +78,23 @@ } } }, + "light": { + "bed_light": { + "state_attributes": { + "effect": { + "state": { + "rainbow": "Rainbow" + } + } + } + } + }, "select": { "speed": { "state": { - "light_speed": "Light Speed", - "ludicrous_speed": "Ludicrous Speed", - "ridiculous_speed": "Ridiculous Speed" + "light_speed": "Light speed", + "ludicrous_speed": "Ludicrous speed", + "ridiculous_speed": "Ridiculous speed" } } }, @@ -102,7 +113,7 @@ "model_s": { "state_attributes": { "cleaned_area": { - "name": "Cleaned Area" + "name": "Cleaned area" } } } diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index db33d5038fc..b82cf0352a7 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -218,7 +218,7 @@ class TrackerEntity( entity_description: TrackerEntityDescription _attr_latitude: float | None = None - _attr_location_accuracy: int = 0 + _attr_location_accuracy: float = 0 _attr_location_name: str | None = None _attr_longitude: float | None = None _attr_source_type: SourceType = SourceType.GPS @@ -234,7 +234,7 @@ class TrackerEntity( return not self.should_poll @cached_property - def location_accuracy(self) -> int: + def location_accuracy(self) -> float: """Return the location accuracy of the device. Value in meters. diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index e86b7b753c8..b8dc948913f 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -3,8 +3,8 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from functools import partial -from types import MappingProxyType from typing import Any from devolo_home_control_api.exceptions.gateway import GatewayOfflineError @@ -97,7 +97,7 @@ async def async_remove_config_entry_device( return True -def configure_mydevolo(conf: dict[str, Any] | MappingProxyType[str, Any]) -> Mydevolo: +def configure_mydevolo(conf: Mapping[str, Any]) -> Mydevolo: """Configure mydevolo.""" mydevolo = Mydevolo() mydevolo.user = conf[CONF_USERNAME] diff --git a/homeassistant/components/devolo_home_control/manifest.json b/homeassistant/components/devolo_home_control/manifest.json index a9715fffa84..983b2a33452 100644 --- a/homeassistant/components/devolo_home_control/manifest.json +++ b/homeassistant/components/devolo_home_control/manifest.json @@ -8,6 +8,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["devolo_home_control_api"], - "requirements": ["devolo-home-control-api==0.18.3"], + "requirements": ["devolo-home-control-api==0.19.0"], "zeroconf": ["_dvl-deviceapi._tcp.local."] } diff --git a/homeassistant/components/devolo_home_network/config_flow.py b/homeassistant/components/devolo_home_network/config_flow.py index bd2f23d602f..ad21289ff28 100644 --- a/homeassistant/components/devolo_home_network/config_flow.py +++ b/homeassistant/components/devolo_home_network/config_flow.py @@ -7,7 +7,7 @@ import logging from typing import Any from devolo_plc_api.device import Device -from devolo_plc_api.exceptions.device import DeviceNotFound +from devolo_plc_api.exceptions.device import DeviceNotFound, DevicePasswordProtected import voluptuous as vol from homeassistant.components import zeroconf @@ -22,7 +22,9 @@ from .const import DOMAIN, PRODUCT, SERIAL_NUMBER, TITLE _LOGGER = logging.getLogger(__name__) -STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_IP_ADDRESS): str}) +STEP_USER_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_IP_ADDRESS): str, vol.Optional(CONF_PASSWORD): str} +) STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_PASSWORD): str}) @@ -36,7 +38,16 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, device = Device(data[CONF_IP_ADDRESS], zeroconf_instance=zeroconf_instance) + device.password = data[CONF_PASSWORD] + await device.async_connect(session_instance=async_client) + + # Try a password protected, non-writing device API call that raises, if the password is wrong. + # If only the plcnet API is available, we can continue without trying a password as the plcnet + # API does not require a password. + if device.device: + await device.device.async_uptime() + await device.async_disconnect() return { @@ -59,23 +70,22 @@ class DevoloHomeNetworkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict = {} - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors - ) - - try: - info = await validate_input(self.hass, user_input) - except DeviceNotFound: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - await self.async_set_unique_id(info[SERIAL_NUMBER], raise_on_progress=False) - self._abort_if_unique_id_configured() - user_input[CONF_PASSWORD] = "" - return self.async_create_entry(title=info[TITLE], data=user_input) + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except DeviceNotFound: + errors["base"] = "cannot_connect" + except DevicePasswordProtected: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + info[SERIAL_NUMBER], raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info[TITLE], data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors @@ -106,15 +116,27 @@ class DevoloHomeNetworkConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle a flow initiated by zeroconf.""" title = self.context["title_placeholders"][CONF_NAME] + errors: dict = {} + data_schema: vol.Schema | None = None + if user_input is not None: data = { CONF_IP_ADDRESS: self.host, - CONF_PASSWORD: "", + CONF_PASSWORD: user_input.get(CONF_PASSWORD, ""), } - return self.async_create_entry(title=title, data=data) + try: + await validate_input(self.hass, data) + except DevicePasswordProtected: + errors = {"base": "invalid_auth"} + data_schema = STEP_REAUTH_DATA_SCHEMA + else: + return self.async_create_entry(title=title, data=data) + return self.async_show_form( step_id="zeroconf_confirm", + data_schema=data_schema, description_placeholders={"host_name": title}, + errors=errors, ) async def async_step_reauth( @@ -134,14 +156,21 @@ class DevoloHomeNetworkConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by reauthentication.""" - if user_input is None: - return self.async_show_form( - step_id="reauth_confirm", - data_schema=STEP_REAUTH_DATA_SCHEMA, - ) + errors: dict = {} + if user_input is not None: + data = { + CONF_IP_ADDRESS: self.host, + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + try: + await validate_input(self.hass, data) + except DevicePasswordProtected: + errors = {"base": "invalid_auth"} + else: + return self.async_update_reload_and_abort(self._reauth_entry, data=data) - data = { - CONF_IP_ADDRESS: self.host, - CONF_PASSWORD: user_input[CONF_PASSWORD], - } - return self.async_update_reload_and_abort(self._reauth_entry, data=data) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index c5862738bd1..cb726e5954c 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -88,6 +88,8 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module ): """Representation of a devolo device tracker.""" + _attr_translation_key = "device_tracker" + def __init__( self, coordinator: DevoloDataUpdateCoordinator[list[ConnectedStationInfo]], @@ -123,13 +125,6 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module ) return attrs - @property - def icon(self) -> str: - """Return device icon.""" - if self.is_connected: - return "mdi:lan-connect" - return "mdi:lan-disconnect" - @property def is_connected(self) -> bool: """Return true if the device is connected to the network.""" diff --git a/homeassistant/components/devolo_home_network/icons.json b/homeassistant/components/devolo_home_network/icons.json index 816d0e36d03..752e5aa3f36 100644 --- a/homeassistant/components/devolo_home_network/icons.json +++ b/homeassistant/components/devolo_home_network/icons.json @@ -13,6 +13,14 @@ "default": "mdi:wifi-plus" } }, + "device_tracker": { + "device_tracker": { + "default": "mdi:lan-disconnect", + "state": { + "home": "mdi:lan-connect" + } + } + }, "sensor": { "connected_plc_devices": { "default": "mdi:lan" diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index d9a6f3f1110..cec1ecc8a81 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -138,7 +138,7 @@ async def async_setup_entry( SENSOR_TYPES[CONNECTED_PLC_DEVICES], ) ) - network = await device.plcnet.async_get_network_overview() + network: LogicalNetwork = coordinators[CONNECTED_PLC_DEVICES].data peers = [ peer.mac_address for peer in network.devices if peer.topology == REMOTE ] diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 4b683b5d2fa..50177a9b13b 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -5,10 +5,12 @@ "user": { "description": "[%key:common::config_flow::description::confirm_setup%]", "data": { - "ip_address": "[%key:common::config_flow::data::ip%]" + "ip_address": "[%key:common::config_flow::data::ip%]", + "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "ip_address": "IP address of your devolo Home Network device. This can be found in the devolo Home Network App on the device dashboard." + "ip_address": "IP address of your devolo Home Network device. This can be found in the devolo Home Network App on the device dashboard.", + "password": "Password you protected the device with." } }, "reauth_confirm": { @@ -16,16 +18,23 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "password": "Password you protected the device with." + "password": "[%key:component::devolo_home_network::config::step::user::data_description::password%]" } }, "zeroconf_confirm": { "description": "Do you want to add the devolo home network device with the hostname `{host_name}` to Home Assistant?", - "title": "Discovered devolo home network device" + "title": "Discovered devolo home network device", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::devolo_home_network::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": { diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index 0271270fa09..b57305a7a77 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -114,9 +114,14 @@ class DevoloSwitchEntity[_DataT: _DataType]( translation_key="password_protected", translation_placeholders={"title": self.entry.title}, ) from ex - except DeviceUnavailable: - pass # The coordinator will handle this - await self.coordinator.async_request_refresh() + except DeviceUnavailable as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_response", + translation_placeholders={"title": self.entry.title}, + ) from ex + finally: + await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" @@ -129,6 +134,11 @@ class DevoloSwitchEntity[_DataT: _DataType]( translation_key="password_protected", translation_placeholders={"title": self.entry.title}, ) from ex - except DeviceUnavailable: - pass # The coordinator will handle this - await self.coordinator.async_request_refresh() + except DeviceUnavailable as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_response", + translation_placeholders={"title": self.entry.title}, + ) from ex + finally: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index a11a0b262b0..76d11f22424 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from dataclasses import dataclass from datetime import timedelta from fnmatch import translate from functools import lru_cache, partial @@ -66,13 +65,12 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo as _DhcpServ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import DHCPMatcher, async_get_dhcp -from .const import DOMAIN +from . import websocket_api +from .const import DOMAIN, HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from .models import DATA_DHCP, DHCPAddressData, DHCPData, DhcpMatchers CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -HOSTNAME: Final = "hostname" -MAC_ADDRESS: Final = "macaddress" -IP_ADDRESS: Final = "ip" REGISTERED_DEVICES: Final = "registered_devices" SCAN_INTERVAL = timedelta(minutes=60) @@ -87,15 +85,6 @@ _DEPRECATED_DhcpServiceInfo = DeprecatedConstant( ) -@dataclass(slots=True) -class DhcpMatchers: - """Prepared info from dhcp entries.""" - - registered_devices_domains: set[str] - no_oui_matchers: dict[str, list[DHCPMatcher]] - oui_matchers: dict[str, list[DHCPMatcher]] - - def async_index_integration_matchers( integration_matchers: list[DHCPMatcher], ) -> DhcpMatchers: @@ -133,36 +122,34 @@ def async_index_integration_matchers( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the dhcp component.""" - watchers: list[WatcherBase] = [] - address_data: dict[str, dict[str, str]] = {} integration_matchers = async_index_integration_matchers(await async_get_dhcp(hass)) + dhcp_data = DHCPData(integration_matchers=integration_matchers) + hass.data[DATA_DHCP] = dhcp_data + websocket_api.async_setup(hass) + watchers: list[WatcherBase] = [] # For the passive classes we need to start listening # for state changes and connect the dispatchers before # everything else starts up or we will miss events - device_watcher = DeviceTrackerWatcher(hass, address_data, integration_matchers) + device_watcher = DeviceTrackerWatcher(hass, dhcp_data) device_watcher.async_start() watchers.append(device_watcher) - device_tracker_registered_watcher = DeviceTrackerRegisteredWatcher( - hass, address_data, integration_matchers - ) + device_tracker_registered_watcher = DeviceTrackerRegisteredWatcher(hass, dhcp_data) device_tracker_registered_watcher.async_start() watchers.append(device_tracker_registered_watcher) async def _async_initialize(event: Event) -> None: await aiodhcpwatcher.async_init() - network_watcher = NetworkWatcher(hass, address_data, integration_matchers) + network_watcher = NetworkWatcher(hass, dhcp_data) network_watcher.async_start() watchers.append(network_watcher) - dhcp_watcher = DHCPWatcher(hass, address_data, integration_matchers) + dhcp_watcher = DHCPWatcher(hass, dhcp_data) await dhcp_watcher.async_start() watchers.append(dhcp_watcher) - rediscovery_watcher = RediscoveryWatcher( - hass, address_data, integration_matchers - ) + rediscovery_watcher = RediscoveryWatcher(hass, dhcp_data) rediscovery_watcher.async_start() watchers.append(rediscovery_watcher) @@ -180,18 +167,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class WatcherBase: """Base class for dhcp and device tracker watching.""" - def __init__( - self, - hass: HomeAssistant, - address_data: dict[str, dict[str, str]], - integration_matchers: DhcpMatchers, - ) -> None: + def __init__(self, hass: HomeAssistant, dhcp_data: DHCPData) -> None: """Initialize class.""" super().__init__() - self.hass = hass - self._integration_matchers = integration_matchers - self._address_data = address_data + self._callbacks = dhcp_data.callbacks + self._integration_matchers = dhcp_data.integration_matchers + self._address_data = dhcp_data.address_data self._unsub: Callable[[], None] | None = None @callback @@ -230,18 +212,18 @@ class WatcherBase: mac_address = formatted_mac.replace(":", "") compressed_ip_address = made_ip_address.compressed - data = self._address_data.get(mac_address) + current_data = self._address_data.get(mac_address) if ( not force - and data - and data[IP_ADDRESS] == compressed_ip_address - and data[HOSTNAME].startswith(hostname) + and current_data + and current_data[IP_ADDRESS] == compressed_ip_address + and current_data[HOSTNAME].startswith(hostname) ): # If the address data is the same no need # to process it return - data = {IP_ADDRESS: compressed_ip_address, HOSTNAME: hostname} + data: DHCPAddressData = {IP_ADDRESS: compressed_ip_address, HOSTNAME: hostname} self._address_data[mac_address] = data lowercase_hostname = hostname.lower() @@ -287,9 +269,19 @@ class WatcherBase: _LOGGER.debug("Matched %s against %s", data, matcher) matched_domains.add(domain) - if not matched_domains: - return # avoid creating DiscoveryKey if there are no matches + if self._callbacks: + address_data = {mac_address: data} + for callback_ in self._callbacks: + callback_(address_data) + service_info: _DhcpServiceInfo | None = None + if not matched_domains: + return + service_info = _DhcpServiceInfo( + ip=ip_address, + hostname=lowercase_hostname, + macaddress=mac_address, + ) discovery_key = DiscoveryKey( domain=DOMAIN, key=mac_address, @@ -300,11 +292,7 @@ class WatcherBase: self.hass, domain, {"source": config_entries.SOURCE_DHCP}, - _DhcpServiceInfo( - ip=ip_address, - hostname=lowercase_hostname, - macaddress=mac_address, - ), + service_info, discovery_key=discovery_key, ) @@ -315,11 +303,10 @@ class NetworkWatcher(WatcherBase): def __init__( self, hass: HomeAssistant, - address_data: dict[str, dict[str, str]], - integration_matchers: DhcpMatchers, + dhcp_data: DHCPData, ) -> None: """Initialize class.""" - super().__init__(hass, address_data, integration_matchers) + super().__init__(hass, dhcp_data) self._discover_hosts: DiscoverHosts | None = None self._discover_task: asyncio.Task | None = None diff --git a/homeassistant/components/dhcp/const.py b/homeassistant/components/dhcp/const.py index c28a699c64c..c3bf8c512db 100644 --- a/homeassistant/components/dhcp/const.py +++ b/homeassistant/components/dhcp/const.py @@ -1,3 +1,8 @@ """Constants for the dhcp integration.""" +from typing import Final + DOMAIN = "dhcp" +HOSTNAME: Final = "hostname" +MAC_ADDRESS: Final = "macaddress" +IP_ADDRESS: Final = "ip" diff --git a/homeassistant/components/dhcp/helpers.py b/homeassistant/components/dhcp/helpers.py new file mode 100644 index 00000000000..e5ab767ee71 --- /dev/null +++ b/homeassistant/components/dhcp/helpers.py @@ -0,0 +1,37 @@ +"""The dhcp integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from functools import partial + +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback + +from .models import DATA_DHCP, DHCPAddressData + + +@callback +def async_register_dhcp_callback_internal( + hass: HomeAssistant, + callback_: Callable[[dict[str, DHCPAddressData]], None], +) -> CALLBACK_TYPE: + """Register a dhcp callback. + + For internal use only. + This is not intended for use by integrations. + """ + callbacks = hass.data[DATA_DHCP].callbacks + callbacks.add(callback_) + return partial(callbacks.remove, callback_) + + +@callback +def async_get_address_data_internal( + hass: HomeAssistant, +) -> dict[str, DHCPAddressData]: + """Get the address data. + + For internal use only. + This is not intended for use by integrations. + """ + return hass.data[DATA_DHCP].address_data diff --git a/homeassistant/components/dhcp/models.py b/homeassistant/components/dhcp/models.py new file mode 100644 index 00000000000..d26993e7f0f --- /dev/null +++ b/homeassistant/components/dhcp/models.py @@ -0,0 +1,43 @@ +"""The dhcp integration.""" + +from __future__ import annotations + +from collections.abc import Callable +import dataclasses +from dataclasses import dataclass +from typing import TypedDict + +from homeassistant.loader import DHCPMatcher +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN + + +@dataclass(slots=True) +class DhcpMatchers: + """Prepared info from dhcp entries.""" + + registered_devices_domains: set[str] + no_oui_matchers: dict[str, list[DHCPMatcher]] + oui_matchers: dict[str, list[DHCPMatcher]] + + +class DHCPAddressData(TypedDict): + """Typed dict for DHCP address data.""" + + hostname: str + ip: str + + +@dataclasses.dataclass(slots=True) +class DHCPData: + """Data for the dhcp component.""" + + integration_matchers: DhcpMatchers + callbacks: set[Callable[[dict[str, DHCPAddressData]], None]] = dataclasses.field( + default_factory=set + ) + address_data: dict[str, DHCPAddressData] = dataclasses.field(default_factory=dict) + + +DATA_DHCP: HassKey[DHCPData] = HassKey(DOMAIN) diff --git a/homeassistant/components/dhcp/websocket_api.py b/homeassistant/components/dhcp/websocket_api.py new file mode 100644 index 00000000000..e6682de2158 --- /dev/null +++ b/homeassistant/components/dhcp/websocket_api.py @@ -0,0 +1,63 @@ +"""The dhcp integration websocket apis.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.json import json_bytes + +from .const import HOSTNAME, IP_ADDRESS +from .helpers import ( + async_get_address_data_internal, + async_register_dhcp_callback_internal, +) +from .models import DHCPAddressData + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the DHCP websocket API.""" + websocket_api.async_register_command(hass, ws_subscribe_discovery) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "dhcp/subscribe_discovery", + } +) +@websocket_api.async_response +async def ws_subscribe_discovery( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe discovery websocket command.""" + ws_msg_id: int = msg["id"] + + def _async_send(address_data: dict[str, DHCPAddressData]) -> None: + connection.send_message( + json_bytes( + websocket_api.event_message( + ws_msg_id, + { + "add": [ + { + "mac_address": dr.format_mac(mac_address).upper(), + "hostname": data[HOSTNAME], + "ip_address": data[IP_ADDRESS], + } + for mac_address, data in address_data.items() + ] + }, + ) + ) + ) + + unsub = async_register_dhcp_callback_internal(hass, _async_send) + connection.subscriptions[ws_msg_id] = unsub + connection.send_message(json_bytes(websocket_api.result_message(ws_msg_id))) + _async_send(async_get_address_data_internal(hass)) diff --git a/homeassistant/components/dialogflow/strings.json b/homeassistant/components/dialogflow/strings.json index 4e59e53ca8c..dab13e31b0c 100644 --- a/homeassistant/components/dialogflow/strings.json +++ b/homeassistant/components/dialogflow/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up the Dialogflow Webhook", + "title": "Set up the Dialogflow webhook", "description": "Are you sure you want to set up Dialogflow?" } }, @@ -12,7 +12,7 @@ "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, "create_entry": { - "default": "To send events to Home Assistant, you will need to set up [webhook integration of Dialogflow]({dialogflow_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) for further details." + "default": "To send events to Home Assistant, you will need to set up the [webhook service of Dialogflow]({dialogflow_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) for further details." } } } diff --git a/homeassistant/components/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json index d25459b95b7..35802adb7f3 100644 --- a/homeassistant/components/dnsip/manifest.json +++ b/homeassistant/components/dnsip/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dnsip", "iot_class": "cloud_polling", - "requirements": ["aiodns==3.2.0"] + "requirements": ["aiodns==3.3.0"] } diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index 2c672dd4abb..cb31c7d6314 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pydoods"], "quality_scale": "legacy", - "requirements": ["pydoods==1.0.2", "Pillow==11.1.0"] + "requirements": ["pydoods==1.0.2", "Pillow==11.2.1"] } diff --git a/homeassistant/components/drop_connect/strings.json b/homeassistant/components/drop_connect/strings.json index 93df4dc3310..6093f2e8100 100644 --- a/homeassistant/components/drop_connect/strings.json +++ b/homeassistant/components/drop_connect/strings.json @@ -38,8 +38,8 @@ "protect_mode": { "name": "Protect mode", "state": { - "away": "Away", - "home": "Home", + "away": "[%key:common::state::not_home%]", + "home": "[%key:common::state::home%]", "schedule": "Schedule" } } diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index 561f06d1bbe..f9e78ac616f 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["dsmr_parser"], - "requirements": ["dsmr-parser==1.4.2"] + "requirements": ["dsmr-parser==1.4.3"] } diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 871dd382f2b..e95e9ae870a 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -51,8 +51,8 @@ "electricity_active_tariff": { "name": "Active tariff", "state": { - "low": "Low", - "normal": "Normal" + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]" } }, "electricity_delivered_tariff_1": { diff --git a/homeassistant/components/dsmr_reader/strings.json b/homeassistant/components/dsmr_reader/strings.json index 90cf0533a72..d405898a393 100644 --- a/homeassistant/components/dsmr_reader/strings.json +++ b/homeassistant/components/dsmr_reader/strings.json @@ -140,8 +140,8 @@ "electricity_tariff": { "name": "Electricity tariff", "state": { - "low": "Low", - "high": "High" + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" } }, "power_failure_count": { diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index 0e491281619..162d1167e81 100644 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Callable -from types import MappingProxyType +from collections.abc import Callable, Mapping from typing import Any from dynalite_devices_lib.dynalite_devices import ( @@ -50,7 +49,7 @@ class DynaliteBridge: LOGGER.debug("Setting up bridge - host %s", self.host) return await self.dynalite_devices.async_setup() - def reload_config(self, config: MappingProxyType[str, Any]) -> None: + def reload_config(self, config: Mapping[str, Any]) -> None: """Reconfigure a bridge when config changes.""" LOGGER.debug("Reloading bridge - host %s, config %s", self.host, config) self.dynalite_devices.configure(convert_config(config)) diff --git a/homeassistant/components/dynalite/convert_config.py b/homeassistant/components/dynalite/convert_config.py index 00edc26f1ab..e37ce93ece4 100644 --- a/homeassistant/components/dynalite/convert_config.py +++ b/homeassistant/components/dynalite/convert_config.py @@ -2,7 +2,7 @@ from __future__ import annotations -from types import MappingProxyType +from collections.abc import Mapping from typing import Any from dynalite_devices_lib import const as dyn_const @@ -138,9 +138,7 @@ def convert_template(config: dict[str, Any]) -> dict[str, Any]: return convert_with_map(config, my_map) -def convert_config( - config: dict[str, Any] | MappingProxyType[str, Any], -) -> dict[str, Any]: +def convert_config(config: Mapping[str, Any]) -> dict[str, Any]: """Convert a config dict by replacing component consts with library consts.""" my_map = { CONF_NAME: dyn_const.CONF_NAME, diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 078643ee789..bc61cb444c1 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -55,7 +55,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to create the vacation." + "description": "ecobee thermostat on which to create the vacation." }, "vacation_name": { "name": "Vacation name", @@ -101,7 +101,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to delete the vacation." + "description": "ecobee thermostat on which to delete the vacation." }, "vacation_name": { "name": "[%key:component::ecobee::services::create_vacation::fields::vacation_name::name%]", @@ -149,7 +149,7 @@ }, "set_mic_mode": { "name": "Set mic mode", - "description": "Enables/disables Alexa microphone (only for Ecobee 4).", + "description": "Enables/disables Alexa microphone (only for ecobee 4).", "fields": { "mic_enabled": { "name": "Mic enabled", @@ -177,7 +177,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to set active sensors." + "description": "ecobee thermostat on which to set active sensors." }, "preset_mode": { "name": "Climate Name", @@ -203,12 +203,12 @@ }, "issues": { "migrate_aux_heat": { - "title": "Migration of Ecobee set_aux_heat action", + "title": "Migration of ecobee set_aux_heat action", "fix_flow": { "step": { "confirm": { - "description": "The Ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.", - "title": "Disable legacy Ecobee set_aux_heat action" + "description": "The ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.", + "title": "Disable legacy ecobee set_aux_heat action" } } } diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index e7ccec33310..56a98c8d630 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -23,10 +23,8 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from . import EconetConfigEntry -from .const import DOMAIN from .entity import EcoNetEntity ECONET_STATE_TO_HA = { @@ -212,34 +210,6 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity): """Set the fan mode.""" self._econet.set_fan_mode(HA_FAN_STATE_TO_ECONET[fan_mode]) - def turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - create_issue( - self.hass, - DOMAIN, - "migrate_aux_heat", - breaks_in_ha_version="2025.4.0", - is_fixable=True, - is_persistent=True, - translation_key="migrate_aux_heat", - severity=IssueSeverity.WARNING, - ) - self._econet.set_mode(ThermostatOperationMode.EMERGENCY_HEAT) - - def turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - create_issue( - self.hass, - DOMAIN, - "migrate_aux_heat", - breaks_in_ha_version="2025.4.0", - is_fixable=True, - is_persistent=True, - translation_key="migrate_aux_heat", - severity=IssueSeverity.WARNING, - ) - self._econet.set_mode(ThermostatOperationMode.HEATING) - @property def min_temp(self): """Return the minimum temperature.""" diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py index 552a8152cc5..73b21d4574d 100644 --- a/homeassistant/components/ecovacs/binary_sensor.py +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Generic from deebot_client.capabilities import CapabilityEvent -from deebot_client.events.water_info import WaterInfoEvent +from deebot_client.events.water_info import MopAttachedEvent from homeassistant.components.binary_sensor import ( BinarySensorEntity, @@ -32,9 +32,9 @@ class EcovacsBinarySensorEntityDescription( ENTITY_DESCRIPTIONS: tuple[EcovacsBinarySensorEntityDescription, ...] = ( - EcovacsBinarySensorEntityDescription[WaterInfoEvent]( - capability_fn=lambda caps: caps.water, - value_fn=lambda e: e.mop_attached, + EcovacsBinarySensorEntityDescription[MopAttachedEvent]( + capability_fn=lambda caps: caps.water.mop_attached if caps.water else None, + value_fn=lambda e: e.value, key="water_mop_attached", translation_key="water_mop_attached", entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/ecovacs/image.py b/homeassistant/components/ecovacs/image.py index f8a89b0cfa0..b1c2f0075f1 100644 --- a/homeassistant/components/ecovacs/image.py +++ b/homeassistant/components/ecovacs/image.py @@ -1,8 +1,11 @@ """Ecovacs image entities.""" +from typing import cast + from deebot_client.capabilities import CapabilityMap from deebot_client.device import Device from deebot_client.events.map import CachedMapInfoEvent, MapChangedEvent +from deebot_client.map import Map from homeassistant.components.image import ImageEntity from homeassistant.core import HomeAssistant @@ -47,6 +50,7 @@ class EcovacsMap( """Initialize entity.""" super().__init__(device, capability, hass=hass) self._attr_extra_state_attributes = {} + self._map = cast(Map, self._device.map) entity_description = EntityDescription( key="map", @@ -55,7 +59,7 @@ class EcovacsMap( def image(self) -> bytes | None: """Return bytes of image or None.""" - if svg := self._device.map.get_svg_map(): + if svg := self._map.get_svg_map(): return svg.encode() return None @@ -80,4 +84,4 @@ class EcovacsMap( Only used by the generic entity update service. """ await super().async_update() - self._device.map.refresh() + self._map.refresh() diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index ad8b3ea70a5..e670a36cf72 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==12.5.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==13.1.0"] } diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py index a7b9baf1c4a..31292401343 100644 --- a/homeassistant/components/ecovacs/select.py +++ b/homeassistant/components/ecovacs/select.py @@ -6,7 +6,8 @@ from typing import Any, Generic from deebot_client.capabilities import CapabilitySetTypes from deebot_client.device import Device -from deebot_client.events import WaterInfoEvent, WorkModeEvent +from deebot_client.events import WorkModeEvent +from deebot_client.events.water_info import WaterAmountEvent from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory @@ -31,9 +32,9 @@ class EcovacsSelectEntityDescription( ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = ( - EcovacsSelectEntityDescription[WaterInfoEvent]( - capability_fn=lambda caps: caps.water, - current_option_fn=lambda e: get_name_key(e.amount), + EcovacsSelectEntityDescription[WaterAmountEvent]( + capability_fn=lambda caps: caps.water.amount if caps.water else None, + current_option_fn=lambda e: get_name_key(e.value), options_fn=lambda water: [get_name_key(amount) for amount in water.types], key="water_amount", translation_key="water_amount", diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 44c51c7ae43..1be81ab1292 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -14,7 +14,7 @@ "step": { "auth": { "data": { - "country": "Country", + "country": "[%key:common::config_flow::data::country%]", "override_rest_url": "REST URL", "override_mqtt_url": "MQTT URL", "password": "[%key:common::config_flow::data::password%]", @@ -176,9 +176,9 @@ "water_amount": { "name": "Water flow level", "state": { - "high": "High", - "low": "Low", - "medium": "Medium", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "ultrahigh": "Ultrahigh" } }, @@ -229,9 +229,9 @@ "state_attributes": { "fan_speed": { "state": { + "normal": "[%key:common::state::normal%]", "max": "Max", "max_plus": "Max+", - "normal": "Normal", "quiet": "Quiet" } }, diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index 26e6bea4d4a..881396ea4af 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -9,7 +9,14 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.LIGHT] +PLATFORMS = [ + Platform.CLIMATE, + Platform.LIGHT, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, + Platform.TIME, +] async def async_setup_entry( diff --git a/homeassistant/components/eheimdigital/icons.json b/homeassistant/components/eheimdigital/icons.json new file mode 100644 index 00000000000..41a362c757c --- /dev/null +++ b/homeassistant/components/eheimdigital/icons.json @@ -0,0 +1,51 @@ +{ + "entity": { + "number": { + "manual_speed": { + "default": "mdi:pump" + }, + "day_speed": { + "default": "mdi:weather-sunny" + }, + "night_speed": { + "default": "mdi:moon-waning-crescent" + }, + "temperature_offset": { + "default": "mdi:thermometer" + }, + "night_temperature_offset": { + "default": "mdi:thermometer" + } + }, + "sensor": { + "current_speed": { + "default": "mdi:pump" + }, + "service_hours": { + "default": "mdi:wrench-clock" + }, + "error_code": { + "default": "mdi:alert-octagon", + "state": { + "no_error": "mdi:check-circle" + } + } + }, + "switch": { + "filter_active": { + "default": "mdi:pump", + "state": { + "off": "mdi:pump-off" + } + } + }, + "time": { + "day_start_time": { + "default": "mdi:weather-sunny" + }, + "night_start_time": { + "default": "mdi:moon-waning-crescent" + } + } + } +} diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index 1d1ca6f84c7..c3c8a251300 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.6"], + "requirements": ["eheimdigital==1.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } ] diff --git a/homeassistant/components/eheimdigital/number.py b/homeassistant/components/eheimdigital/number.py new file mode 100644 index 00000000000..f4504be624c --- /dev/null +++ b/homeassistant/components/eheimdigital/number.py @@ -0,0 +1,177 @@ +"""EHEIM Digital numbers.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Generic, TypeVar, override + +from eheimdigital.classic_vario import EheimDigitalClassicVario +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.heater import EheimDigitalHeater +from eheimdigital.types import HeaterUnit + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import ( + PERCENTAGE, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + EntityCategory, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +PARALLEL_UPDATES = 0 + +_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) + + +@dataclass(frozen=True, kw_only=True) +class EheimDigitalNumberDescription(NumberEntityDescription, Generic[_DeviceT_co]): + """Class describing EHEIM Digital sensor entities.""" + + value_fn: Callable[[_DeviceT_co], float | None] + set_value_fn: Callable[[_DeviceT_co, float], Awaitable[None]] + uom_fn: Callable[[_DeviceT_co], str] | None = None + + +CLASSICVARIO_DESCRIPTIONS: tuple[ + EheimDigitalNumberDescription[EheimDigitalClassicVario], ... +] = ( + EheimDigitalNumberDescription[EheimDigitalClassicVario]( + key="manual_speed", + translation_key="manual_speed", + entity_category=EntityCategory.CONFIG, + native_step=PRECISION_WHOLE, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.manual_speed, + set_value_fn=lambda device, value: device.set_manual_speed(int(value)), + ), + EheimDigitalNumberDescription[EheimDigitalClassicVario]( + key="day_speed", + translation_key="day_speed", + entity_category=EntityCategory.CONFIG, + native_step=PRECISION_WHOLE, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.day_speed, + set_value_fn=lambda device, value: device.set_day_speed(int(value)), + ), + EheimDigitalNumberDescription[EheimDigitalClassicVario]( + key="night_speed", + translation_key="night_speed", + entity_category=EntityCategory.CONFIG, + native_step=PRECISION_WHOLE, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.night_speed, + set_value_fn=lambda device, value: device.set_night_speed(int(value)), + ), +) + +HEATER_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalHeater], ...] = ( + EheimDigitalNumberDescription[EheimDigitalHeater]( + key="temperature_offset", + translation_key="temperature_offset", + entity_category=EntityCategory.CONFIG, + native_min_value=-3, + native_max_value=3, + native_step=PRECISION_TENTHS, + device_class=NumberDeviceClass.TEMPERATURE, + uom_fn=lambda device: ( + UnitOfTemperature.CELSIUS + if device.temperature_unit is HeaterUnit.CELSIUS + else UnitOfTemperature.FAHRENHEIT + ), + value_fn=lambda device: device.temperature_offset, + set_value_fn=lambda device, value: device.set_temperature_offset(value), + ), + EheimDigitalNumberDescription[EheimDigitalHeater]( + key="night_temperature_offset", + translation_key="night_temperature_offset", + entity_category=EntityCategory.CONFIG, + native_min_value=-5, + native_max_value=5, + native_step=PRECISION_HALVES, + device_class=NumberDeviceClass.TEMPERATURE, + uom_fn=lambda device: ( + UnitOfTemperature.CELSIUS + if device.temperature_unit is HeaterUnit.CELSIUS + else UnitOfTemperature.FAHRENHEIT + ), + value_fn=lambda device: device.night_temperature_offset, + set_value_fn=lambda device, value: device.set_night_temperature_offset(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so numbers can be added as devices are found.""" + coordinator = entry.runtime_data + + def async_setup_device_entities( + device_address: dict[str, EheimDigitalDevice], + ) -> None: + """Set up the number entities for one or multiple devices.""" + entities: list[EheimDigitalNumber[EheimDigitalDevice]] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicVario): + entities.extend( + EheimDigitalNumber[EheimDigitalClassicVario]( + coordinator, device, description + ) + for description in CLASSICVARIO_DESCRIPTIONS + ) + if isinstance(device, EheimDigitalHeater): + entities.extend( + EheimDigitalNumber[EheimDigitalHeater]( + coordinator, device, description + ) + for description in HEATER_DESCRIPTIONS + ) + + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + async_setup_device_entities(coordinator.hub.devices) + + +class EheimDigitalNumber( + EheimDigitalEntity[_DeviceT_co], NumberEntity, Generic[_DeviceT_co] +): + """Represent a EHEIM Digital number entity.""" + + entity_description: EheimDigitalNumberDescription[_DeviceT_co] + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: _DeviceT_co, + description: EheimDigitalNumberDescription[_DeviceT_co], + ) -> None: + """Initialize an EHEIM Digital number entity.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{self._device_address}_{description.key}" + + @override + async def async_set_native_value(self, value: float) -> None: + return await self.entity_description.set_value_fn(self._device, value) + + @override + def _async_update_attrs(self) -> None: + self._attr_native_value = self.entity_description.value_fn(self._device) + self._attr_native_unit_of_measurement = ( + self.entity_description.uom_fn(self._device) + if self.entity_description.uom_fn + else self.entity_description.native_unit_of_measurement + ) diff --git a/homeassistant/components/eheimdigital/sensor.py b/homeassistant/components/eheimdigital/sensor.py new file mode 100644 index 00000000000..3d809cc14dc --- /dev/null +++ b/homeassistant/components/eheimdigital/sensor.py @@ -0,0 +1,114 @@ +"""EHEIM Digital sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic, TypeVar, override + +from eheimdigital.classic_vario import EheimDigitalClassicVario +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.types import FilterErrorCode + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor.const import SensorDeviceClass +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + +_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) + + +@dataclass(frozen=True, kw_only=True) +class EheimDigitalSensorDescription(SensorEntityDescription, Generic[_DeviceT_co]): + """Class describing EHEIM Digital sensor entities.""" + + value_fn: Callable[[_DeviceT_co], float | str | None] + + +CLASSICVARIO_DESCRIPTIONS: tuple[ + EheimDigitalSensorDescription[EheimDigitalClassicVario], ... +] = ( + EheimDigitalSensorDescription[EheimDigitalClassicVario]( + key="current_speed", + translation_key="current_speed", + value_fn=lambda device: device.current_speed, + native_unit_of_measurement=PERCENTAGE, + ), + EheimDigitalSensorDescription[EheimDigitalClassicVario]( + key="service_hours", + translation_key="service_hours", + value_fn=lambda device: device.service_hours, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + suggested_unit_of_measurement=UnitOfTime.DAYS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + EheimDigitalSensorDescription[EheimDigitalClassicVario]( + key="error_code", + translation_key="error_code", + value_fn=( + lambda device: device.error_code.name.lower() + if device.error_code is not None + else None + ), + device_class=SensorDeviceClass.ENUM, + options=[name.lower() for name in FilterErrorCode._member_names_], + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so lights can be added as devices are found.""" + coordinator = entry.runtime_data + + def async_setup_device_entities( + device_address: dict[str, EheimDigitalDevice], + ) -> None: + """Set up the light entities for one or multiple devices.""" + entities: list[EheimDigitalSensor[EheimDigitalDevice]] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicVario): + entities += [ + EheimDigitalSensor[EheimDigitalClassicVario]( + coordinator, device, description + ) + for description in CLASSICVARIO_DESCRIPTIONS + ] + + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + async_setup_device_entities(coordinator.hub.devices) + + +class EheimDigitalSensor( + EheimDigitalEntity[_DeviceT_co], SensorEntity, Generic[_DeviceT_co] +): + """Represent a EHEIM Digital sensor entity.""" + + entity_description: EheimDigitalSensorDescription[_DeviceT_co] + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: _DeviceT_co, + description: EheimDigitalSensorDescription[_DeviceT_co], + ) -> None: + """Initialize an EHEIM Digital number entity.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{self._device_address}_{description.key}" + + @override + def _async_update_attrs(self) -> None: + self._attr_native_value = self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json index ef6f6b10d0a..97a3fbe4e0d 100644 --- a/homeassistant/components/eheimdigital/strings.json +++ b/homeassistant/components/eheimdigital/strings.json @@ -46,6 +46,47 @@ } } } + }, + "number": { + "manual_speed": { + "name": "Manual speed" + }, + "day_speed": { + "name": "Day speed" + }, + "night_speed": { + "name": "Night speed" + }, + "temperature_offset": { + "name": "Temperature offset" + }, + "night_temperature_offset": { + "name": "Night temperature offset" + } + }, + "sensor": { + "current_speed": { + "name": "Current speed" + }, + "service_hours": { + "name": "Remaining hours until service" + }, + "error_code": { + "name": "Error code", + "state": { + "no_error": "No error", + "rotor_stuck": "Rotor stuck", + "air_in_filter": "Air in filter" + } + } + }, + "time": { + "day_start_time": { + "name": "Day start time" + }, + "night_start_time": { + "name": "Night start time" + } } } } diff --git a/homeassistant/components/eheimdigital/switch.py b/homeassistant/components/eheimdigital/switch.py new file mode 100644 index 00000000000..de23feff322 --- /dev/null +++ b/homeassistant/components/eheimdigital/switch.py @@ -0,0 +1,70 @@ +"""EHEIM Digital switches.""" + +from typing import Any, override + +from eheimdigital.classic_vario import EheimDigitalClassicVario +from eheimdigital.device import EheimDigitalDevice + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so switches can be added as devices are found.""" + coordinator = entry.runtime_data + + def async_setup_device_entities( + device_address: dict[str, EheimDigitalDevice], + ) -> None: + """Set up the switch entities for one or multiple devices.""" + entities: list[SwitchEntity] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicVario): + entities.append(EheimDigitalClassicVarioSwitch(coordinator, device)) # noqa: PERF401 + + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + async_setup_device_entities(coordinator.hub.devices) + + +class EheimDigitalClassicVarioSwitch( + EheimDigitalEntity[EheimDigitalClassicVario], SwitchEntity +): + """Represent an EHEIM Digital classicVARIO switch entity.""" + + _attr_translation_key = "filter_active" + _attr_name = None + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: EheimDigitalClassicVario, + ) -> None: + """Initialize an EHEIM Digital classicVARIO switch entity.""" + super().__init__(coordinator, device) + self._attr_unique_id = device.mac_address + self._async_update_attrs() + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + await self._device.set_active(active=False) + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + await self._device.set_active(active=True) + + @override + def _async_update_attrs(self) -> None: + self._attr_is_on = self._device.is_active diff --git a/homeassistant/components/eheimdigital/time.py b/homeassistant/components/eheimdigital/time.py new file mode 100644 index 00000000000..ae64fad0c92 --- /dev/null +++ b/homeassistant/components/eheimdigital/time.py @@ -0,0 +1,132 @@ +"""EHEIM Digital time entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import time +from typing import Generic, TypeVar, final, override + +from eheimdigital.classic_vario import EheimDigitalClassicVario +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.heater import EheimDigitalHeater + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +PARALLEL_UPDATES = 0 + +_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) + + +@dataclass(frozen=True, kw_only=True) +class EheimDigitalTimeDescription(TimeEntityDescription, Generic[_DeviceT_co]): + """Class describing EHEIM Digital time entities.""" + + value_fn: Callable[[_DeviceT_co], time | None] + set_value_fn: Callable[[_DeviceT_co, time], Awaitable[None]] + + +CLASSICVARIO_DESCRIPTIONS: tuple[ + EheimDigitalTimeDescription[EheimDigitalClassicVario], ... +] = ( + EheimDigitalTimeDescription[EheimDigitalClassicVario]( + key="day_start_time", + translation_key="day_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.day_start_time, + set_value_fn=lambda device, value: device.set_day_start_time(value), + ), + EheimDigitalTimeDescription[EheimDigitalClassicVario]( + key="night_start_time", + translation_key="night_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.night_start_time, + set_value_fn=lambda device, value: device.set_night_start_time(value), + ), +) + +HEATER_DESCRIPTIONS: tuple[EheimDigitalTimeDescription[EheimDigitalHeater], ...] = ( + EheimDigitalTimeDescription[EheimDigitalHeater]( + key="day_start_time", + translation_key="day_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.day_start_time, + set_value_fn=lambda device, value: device.set_day_start_time(value), + ), + EheimDigitalTimeDescription[EheimDigitalHeater]( + key="night_start_time", + translation_key="night_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.night_start_time, + set_value_fn=lambda device, value: device.set_night_start_time(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so times can be added as devices are found.""" + coordinator = entry.runtime_data + + def async_setup_device_entities( + device_address: dict[str, EheimDigitalDevice], + ) -> None: + """Set up the time entities for one or multiple devices.""" + entities: list[EheimDigitalTime[EheimDigitalDevice]] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicVario): + entities.extend( + EheimDigitalTime[EheimDigitalClassicVario]( + coordinator, device, description + ) + for description in CLASSICVARIO_DESCRIPTIONS + ) + if isinstance(device, EheimDigitalHeater): + entities.extend( + EheimDigitalTime[EheimDigitalHeater]( + coordinator, device, description + ) + for description in HEATER_DESCRIPTIONS + ) + + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + async_setup_device_entities(coordinator.hub.devices) + + +@final +class EheimDigitalTime( + EheimDigitalEntity[_DeviceT_co], TimeEntity, Generic[_DeviceT_co] +): + """Represent an EHEIM Digital time entity.""" + + entity_description: EheimDigitalTimeDescription[_DeviceT_co] + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: _DeviceT_co, + description: EheimDigitalTimeDescription[_DeviceT_co], + ) -> None: + """Initialize an EHEIM Digital time entity.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{device.mac_address}_{description.key}" + + @override + async def async_set_value(self, value: time) -> None: + """Change the time.""" + return await self.entity_description.set_value_fn(self._device, value) + + @override + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + self._attr_native_value = self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index efcadb3f440..61850837075 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping import logging -from types import MappingProxyType from typing import Any from elevenlabs import AsyncElevenLabs @@ -43,7 +43,7 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -def to_voice_settings(options: MappingProxyType[str, Any]) -> VoiceSettings: +def to_voice_settings(options: Mapping[str, Any]) -> VoiceSettings: """Return voice settings.""" return VoiceSettings( stability=options.get(CONF_STABILITY, DEFAULT_STABILITY), diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 4bf51b99de1..0fe2df09bc5 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio import logging import re -from types import MappingProxyType from typing import Any from elkm1_lib.elements import Element @@ -235,7 +234,7 @@ def _async_find_matching_config_entry( async def async_setup_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> bool: """Set up Elk-M1 Control from a config entry.""" - conf: MappingProxyType[str, Any] = entry.data + conf = entry.data host = hostname_from_url(entry.data[CONF_HOST]) diff --git a/homeassistant/components/elmax/strings.json b/homeassistant/components/elmax/strings.json index 2ba74f5fc8f..5bc7eb292a2 100644 --- a/homeassistant/components/elmax/strings.json +++ b/homeassistant/components/elmax/strings.json @@ -4,12 +4,12 @@ "choose_mode": { "description": "Please choose the connection mode to Elmax panels.", "menu_options": { - "cloud": "Connect to Elmax Panel via Elmax Cloud APIs", - "direct": "Connect to Elmax Panel via local/direct IP" + "cloud": "Connect to Elmax panel via Elmax Cloud APIs", + "direct": "Connect to Elmax panel via local/direct IP" } }, "cloud": { - "description": "Please login to the Elmax cloud using your credentials", + "description": "Please log in to the Elmax cloud using your credentials", "data": { "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" @@ -28,7 +28,7 @@ "direct": { "description": "Specify the Elmax panel connection parameters below.", "data": { - "panel_api_host": "Panel API Hostname or IP", + "panel_api_host": "Panel API hostname or IP", "panel_api_port": "Panel API port", "use_ssl": "Use SSL", "panel_pin": "Panel PIN code" @@ -40,7 +40,7 @@ "panels": { "description": "Select which panel you would like to control with this integration. Please note that the panel must be ON in order to be configured.", "data": { - "panel_name": "Panel Name", + "panel_name": "Panel name", "panel_id": "Panel ID", "panel_pin": "[%key:common::config_flow::data::pin%]" } diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index e0d4d0d03e9..8b3067b2cf4 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -17,7 +17,6 @@ from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import selector -from homeassistant.helpers.typing import ConfigType from .const import ( CONF_MESSAGE, @@ -27,7 +26,6 @@ from .const import ( FEED_ID, FEED_NAME, FEED_TAG, - LOGGER, ) @@ -153,24 +151,6 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_info: ConfigType) -> ConfigFlowResult: - """Import config from yaml.""" - url = import_info[CONF_URL] - api_key = import_info[CONF_API_KEY] - include_only_feeds = None - if import_info.get(CONF_ONLY_INCLUDE_FEEDID) is not None: - include_only_feeds = list(map(str, import_info[CONF_ONLY_INCLUDE_FEEDID])) - config = { - CONF_API_KEY: api_key, - CONF_ONLY_INCLUDE_FEEDID: include_only_feeds, - CONF_URL: url, - } - LOGGER.debug(config) - result = await self.async_step_user(config) - if errors := result.get("errors"): - return self.async_abort(reason=errors["base"]) - return result - class EmoncmsOptionsFlow(OptionsFlow): """Emoncms Options flow handler.""" diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 6321ccfafcd..c5a25104549 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -4,24 +4,16 @@ from __future__ import annotations from typing import Any -import voluptuous as vol - from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, - CONF_API_KEY, - CONF_ID, - CONF_UNIT_OF_MEASUREMENT, CONF_URL, - CONF_VALUE_TEMPLATE, PERCENTAGE, UnitOfApparentPower, UnitOfElectricCurrent, @@ -36,22 +28,15 @@ from homeassistant.const import ( UnitOfVolume, UnitOfVolumeFlowRate, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity_platform import ( - AddConfigEntryEntitiesCallback, - AddEntitiesCallback, -) -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import template +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .config_flow import sensor_name from .const import ( CONF_EXCLUDE_FEEDID, CONF_ONLY_INCLUDE_FEEDID, - DOMAIN, FEED_ID, FEED_NAME, FEED_TAG, @@ -205,88 +190,7 @@ ATTR_LASTUPDATETIMESTR = "LastUpdatedStr" ATTR_SIZE = "Size" ATTR_TAG = "Tag" ATTR_USERID = "UserId" -CONF_SENSOR_NAMES = "sensor_names" DECIMALS = 2 -DEFAULT_UNIT = UnitOfPower.WATT - -ONLY_INCL_EXCL_NONE = "only_include_exclude_or_none" - -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_URL): cv.string, - vol.Required(CONF_ID): cv.positive_int, - vol.Exclusive(CONF_ONLY_INCLUDE_FEEDID, ONLY_INCL_EXCL_NONE): vol.All( - cv.ensure_list, [cv.positive_int] - ), - vol.Exclusive(CONF_EXCLUDE_FEEDID, ONLY_INCL_EXCL_NONE): vol.All( - cv.ensure_list, [cv.positive_int] - ), - vol.Optional(CONF_SENSOR_NAMES): vol.All( - {cv.positive_int: vol.All(cv.string, vol.Length(min=1))} - ), - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=DEFAULT_UNIT): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Import config from yaml.""" - if CONF_VALUE_TEMPLATE in config: - async_create_issue( - hass, - DOMAIN, - f"remove_{CONF_VALUE_TEMPLATE}_{DOMAIN}", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.ERROR, - translation_key=f"remove_{CONF_VALUE_TEMPLATE}", - translation_placeholders={ - "domain": DOMAIN, - "parameter": CONF_VALUE_TEMPLATE, - }, - ) - return - if CONF_ONLY_INCLUDE_FEEDID not in config: - async_create_issue( - hass, - DOMAIN, - f"missing_{CONF_ONLY_INCLUDE_FEEDID}_{DOMAIN}", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key=f"missing_{CONF_ONLY_INCLUDE_FEEDID}", - translation_placeholders={ - "domain": DOMAIN, - }, - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - if ( - result.get("type") == FlowResultType.CREATE_ENTRY - or result.get("reason") == "already_configured" - ): - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - is_fixable=False, - issue_domain=DOMAIN, - breaks_in_ha_version="2025.3.0", - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "emoncms", - }, - ) async def async_setup_entry( diff --git a/homeassistant/components/emulated_roku/__init__.py b/homeassistant/components/emulated_roku/__init__.py index d4466f47ef2..e8c3a00f098 100644 --- a/homeassistant/components/emulated_roku/__init__.py +++ b/homeassistant/components/emulated_roku/__init__.py @@ -46,6 +46,8 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +type EmulatedRokuConfigEntry = ConfigEntry[EmulatedRoku] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the emulated roku component.""" @@ -65,22 +67,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: EmulatedRokuConfigEntry +) -> bool: """Set up an emulated roku server from a config entry.""" - config = config_entry.data - - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - - name = config[CONF_NAME] - listen_port = config[CONF_LISTEN_PORT] - host_ip = config.get(CONF_HOST_IP) or await async_get_source_ip(hass) - advertise_ip = config.get(CONF_ADVERTISE_IP) - advertise_port = config.get(CONF_ADVERTISE_PORT) - upnp_bind_multicast = config.get(CONF_UPNP_BIND_MULTICAST) + config = entry.data + name: str = config[CONF_NAME] + listen_port: int = config[CONF_LISTEN_PORT] + host_ip: str = config.get(CONF_HOST_IP) or await async_get_source_ip(hass) + advertise_ip: str | None = config.get(CONF_ADVERTISE_IP) + advertise_port: int | None = config.get(CONF_ADVERTISE_PORT) + upnp_bind_multicast: bool | None = config.get(CONF_UPNP_BIND_MULTICAST) server = EmulatedRoku( hass, + entry.entry_id, name, host_ip, listen_port, @@ -88,14 +89,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b advertise_port, upnp_bind_multicast, ) - - hass.data[DOMAIN][name] = server - + entry.runtime_data = server return await server.setup() -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: EmulatedRokuConfigEntry +) -> bool: """Unload a config entry.""" - name = entry.data[CONF_NAME] - server = hass.data[DOMAIN].pop(name) - return await server.unload() + return await entry.runtime_data.unload() diff --git a/homeassistant/components/emulated_roku/binding.py b/homeassistant/components/emulated_roku/binding.py index a84db4bd77b..6d8d9c4014f 100644 --- a/homeassistant/components/emulated_roku/binding.py +++ b/homeassistant/components/emulated_roku/binding.py @@ -5,7 +5,13 @@ import logging from emulated_roku import EmulatedRokuCommandHandler, EmulatedRokuServer from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CoreState, EventOrigin +from homeassistant.core import ( + CALLBACK_TYPE, + CoreState, + Event, + EventOrigin, + HomeAssistant, +) LOGGER = logging.getLogger(__package__) @@ -27,16 +33,18 @@ class EmulatedRoku: def __init__( self, - hass, - name, - host_ip, - listen_port, - advertise_ip, - advertise_port, - upnp_bind_multicast, - ): + hass: HomeAssistant, + entry_id: str, + name: str, + host_ip: str, + listen_port: int, + advertise_ip: str | None, + advertise_port: int | None, + upnp_bind_multicast: bool | None, + ) -> None: """Initialize the properties.""" self.hass = hass + self.entry_id = entry_id self.roku_usn = name self.host_ip = host_ip @@ -47,21 +55,21 @@ class EmulatedRoku: self.bind_multicast = upnp_bind_multicast - self._api_server = None + self._api_server: EmulatedRokuServer | None = None - self._unsub_start_listener = None - self._unsub_stop_listener = None + self._unsub_start_listener: CALLBACK_TYPE | None = None + self._unsub_stop_listener: CALLBACK_TYPE | None = None - async def setup(self): + async def setup(self) -> bool: """Start the emulated_roku server.""" class EventCommandHandler(EmulatedRokuCommandHandler): """emulated_roku command handler to turn commands into events.""" - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: self.hass = hass - def on_keydown(self, roku_usn, key): + def on_keydown(self, roku_usn: str, key: str) -> None: """Handle keydown event.""" self.hass.bus.async_fire( EVENT_ROKU_COMMAND, @@ -73,7 +81,7 @@ class EmulatedRoku: EventOrigin.local, ) - def on_keyup(self, roku_usn, key): + def on_keyup(self, roku_usn: str, key: str) -> None: """Handle keyup event.""" self.hass.bus.async_fire( EVENT_ROKU_COMMAND, @@ -85,7 +93,7 @@ class EmulatedRoku: EventOrigin.local, ) - def on_keypress(self, roku_usn, key): + def on_keypress(self, roku_usn: str, key: str) -> None: """Handle keypress event.""" self.hass.bus.async_fire( EVENT_ROKU_COMMAND, @@ -97,7 +105,7 @@ class EmulatedRoku: EventOrigin.local, ) - def launch(self, roku_usn, app_id): + def launch(self, roku_usn: str, app_id: str) -> None: """Handle launch event.""" self.hass.bus.async_fire( EVENT_ROKU_COMMAND, @@ -129,17 +137,19 @@ class EmulatedRoku: bind_multicast=self.bind_multicast, ) - async def emulated_roku_stop(event): + async def emulated_roku_stop(event: Event | None) -> None: """Wrap the call to emulated_roku.close.""" LOGGER.debug("Stopping emulated_roku %s", self.roku_usn) self._unsub_stop_listener = None + assert self._api_server is not None await self._api_server.close() - async def emulated_roku_start(event): + async def emulated_roku_start(event: Event | None) -> None: """Wrap the call to emulated_roku.start.""" try: LOGGER.debug("Starting emulated_roku %s", self.roku_usn) self._unsub_start_listener = None + assert self._api_server is not None await self._api_server.start() except OSError: LOGGER.exception( @@ -165,7 +175,7 @@ class EmulatedRoku: return True - async def unload(self): + async def unload(self) -> bool: """Unload the emulated_roku server.""" LOGGER.debug("Unloading emulated_roku %s", self.roku_usn) @@ -177,6 +187,7 @@ class EmulatedRoku: self._unsub_stop_listener() self._unsub_stop_listener = None + assert self._api_server is not None await self._api_server.close() return True diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index eec92c32f98..062601eb4c5 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -25,6 +25,7 @@ from homeassistant.core import ( split_entity_id, valid_entity_id, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event @@ -122,6 +123,10 @@ SOURCE_ADAPTERS: Final = ( ) +class EntityNotFoundError(HomeAssistantError): + """When a referenced entity was not found.""" + + class SensorManager: """Class to handle creation/removal of sensor data.""" @@ -311,43 +316,25 @@ class EnergyCostSensor(SensorEntity): except ValueError: return - # Determine energy price - if self._config["entity_energy_price"] is not None: - energy_price_state = self.hass.states.get( - self._config["entity_energy_price"] + try: + energy_price, energy_price_unit = self._get_energy_price( + valid_units, default_price_unit ) - - if energy_price_state is None: - return - - try: - energy_price = float(energy_price_state.state) - except ValueError: - if self._last_energy_sensor_state is None: - # Initialize as it's the first time all required entities except - # price are in place. This means that the cost will update the first - # time the energy is updated after the price entity is in place. - self._reset(energy_state) - return - - energy_price_unit: str | None = energy_price_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT, "" - ).partition("/")[2] - - # For backwards compatibility we don't validate the unit of the price - # If it is not valid, we assume it's our default price unit. - if energy_price_unit not in valid_units: - energy_price_unit = default_price_unit - - else: - energy_price = cast(float, self._config["number_energy_price"]) - energy_price_unit = default_price_unit + except EntityNotFoundError: + return + except ValueError: + energy_price = None if self._last_energy_sensor_state is None: - # Initialize as it's the first time all required entities are in place. + # Initialize as it's the first time all required entities are in place or + # only the price is missing. In the later case, cost will update the first + # time the energy is updated after the price entity is in place. self._reset(energy_state) return + if energy_price is None: + return + energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if energy_unit is None or energy_unit not in valid_units: @@ -383,20 +370,9 @@ class EnergyCostSensor(SensorEntity): old_energy_value = float(self._last_energy_sensor_state.state) cur_value = cast(float, self._attr_native_value) - if energy_price_unit is None: - converted_energy_price = energy_price - else: - converter: Callable[[float, str, str], float] - if energy_unit in VALID_ENERGY_UNITS: - converter = unit_conversion.EnergyConverter.convert - else: - converter = unit_conversion.VolumeConverter.convert - - converted_energy_price = converter( - energy_price, - energy_unit, - energy_price_unit, - ) + converted_energy_price = self._convert_energy_price( + energy_price, energy_price_unit, energy_unit + ) self._attr_native_value = ( cur_value + (energy - old_energy_value) * converted_energy_price @@ -404,6 +380,52 @@ class EnergyCostSensor(SensorEntity): self._last_energy_sensor_state = energy_state + def _get_energy_price( + self, valid_units: set[str], default_unit: str | None + ) -> tuple[float, str | None]: + """Get the energy price. + + Raises: + EntityNotFoundError: When the energy price entity is not found. + ValueError: When the entity state is not a valid float. + + """ + + if self._config["entity_energy_price"] is None: + return cast(float, self._config["number_energy_price"]), default_unit + + energy_price_state = self.hass.states.get(self._config["entity_energy_price"]) + if energy_price_state is None: + raise EntityNotFoundError + + energy_price = float(energy_price_state.state) + + energy_price_unit: str | None = energy_price_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT, "" + ).partition("/")[2] + + # For backwards compatibility we don't validate the unit of the price + # If it is not valid, we assume it's our default price unit. + if energy_price_unit not in valid_units: + energy_price_unit = default_unit + + return energy_price, energy_price_unit + + def _convert_energy_price( + self, energy_price: float, energy_price_unit: str | None, energy_unit: str + ) -> float: + """Convert the energy price to the correct unit.""" + if energy_price_unit is None: + return energy_price + + converter: Callable[[float, str, str], float] + if energy_unit in VALID_ENERGY_UNITS: + converter = unit_conversion.EnergyConverter.convert + else: + converter = unit_conversion.VolumeConverter.convert + + return converter(energy_price, energy_unit, energy_price_unit) + async def async_added_to_hass(self) -> None: """Register callbacks.""" energy_state = self.hass.states.get(self._config[self._adapter.stat_energy_key]) diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index 5f48a99133d..d9d36deb03e 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -293,9 +293,9 @@ async def ws_get_fossil_energy_consumption( if statistics_id not in statistic_ids: continue for period in stat: - if period["change"] is None: + if (change := period.get("change")) is None: continue - result[period["start"]] += period["change"] + result[period["start"]] += change return {key: result[key] for key in sorted(result)} diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 654e2262730..5ee81dd8315 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -16,7 +16,13 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + 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 @@ -40,6 +46,13 @@ CONF_SERIAL = "serial" INSTALLER_AUTH_USERNAME = "installer" +AVOID_REFLECT_KEYS = {CONF_PASSWORD, CONF_TOKEN} + + +def without_avoid_reflect_keys(dictionary: Mapping[str, Any]) -> dict[str, Any]: + """Return a dictionary without AVOID_REFLECT_KEYS.""" + return {k: v for k, v in dictionary.items() if k not in AVOID_REFLECT_KEYS} + async def validate_input( hass: HomeAssistant, @@ -205,7 +218,10 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders["serial"] = serial return self.async_show_form( step_id="reauth_confirm", - data_schema=self._async_generate_schema(), + data_schema=self.add_suggested_values_to_schema( + self._async_generate_schema(), + without_avoid_reflect_keys(user_input or reauth_entry.data), + ), description_placeholders=description_placeholders, errors=errors, ) @@ -259,10 +275,12 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): CONF_SERIAL: self.unique_id, CONF_HOST: host, } - return self.async_show_form( step_id="user", - data_schema=self._async_generate_schema(), + data_schema=self.add_suggested_values_to_schema( + self._async_generate_schema(), + without_avoid_reflect_keys(user_input or {}), + ), description_placeholders=description_placeholders, errors=errors, ) @@ -306,11 +324,11 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): } description_placeholders["serial"] = serial - suggested_values: Mapping[str, Any] = user_input or reconfigure_entry.data return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( - self._async_generate_schema(), suggested_values + self._async_generate_schema(), + without_avoid_reflect_keys(user_input or reconfigure_entry.data), ), description_placeholders=description_placeholders, errors=errors, diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index b8cda03a451..40c690b29ec 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -9,12 +9,14 @@ import logging from typing import Any from pyenphase import Envoy, EnvoyError, EnvoyTokenAuth +from pyenphase.models.home import EnvoyInterfaceInformation from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -26,7 +28,7 @@ TOKEN_REFRESH_CHECK_INTERVAL = timedelta(days=1) STALE_TOKEN_THRESHOLD = timedelta(days=30).total_seconds() NOTIFICATION_ID = "enphase_envoy_notification" FIRMWARE_REFRESH_INTERVAL = timedelta(hours=4) - +MAC_VERIFICATION_DELAY = timedelta(seconds=34) _LOGGER = logging.getLogger(__name__) @@ -39,6 +41,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): envoy_serial_number: str envoy_firmware: str config_entry: EnphaseConfigEntry + interface: EnvoyInterfaceInformation | None def __init__( self, hass: HomeAssistant, envoy: Envoy, entry: EnphaseConfigEntry @@ -50,8 +53,10 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.password = entry_data[CONF_PASSWORD] self._setup_complete = False self.envoy_firmware = "" + self.interface = None self._cancel_token_refresh: CALLBACK_TYPE | None = None self._cancel_firmware_refresh: CALLBACK_TYPE | None = None + self._cancel_mac_verification: CALLBACK_TYPE | None = None super().__init__( hass, _LOGGER, @@ -121,6 +126,66 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.hass.config_entries.async_reload(self.config_entry.entry_id) ) + def _schedule_mac_verification( + self, delay: timedelta = MAC_VERIFICATION_DELAY + ) -> None: + """Schedule one time job to verify envoy mac address.""" + self.async_cancel_mac_verification() + self._cancel_mac_verification = async_call_later( + self.hass, + delay, + self._async_verify_mac, + ) + + @callback + def _async_verify_mac(self, now: datetime.datetime) -> None: + """Verify Envoy active interface mac address in background.""" + self.hass.async_create_background_task( + self._async_fetch_and_compare_mac(), "{name} verify envoy mac address" + ) + + async def _async_fetch_and_compare_mac(self) -> None: + """Get Envoy interface information and update mac in device connections.""" + interface: ( + EnvoyInterfaceInformation | None + ) = await self.envoy.interface_settings() + if interface is None: + _LOGGER.debug("%s: interface information returned None", self.name) + return + # remember interface information so diagnostics can include in report + self.interface = interface + + # Add to or update device registry connections as needed + device_registry = dr.async_get(self.hass) + envoy_device = device_registry.async_get_device( + identifiers={ + ( + DOMAIN, + self.envoy_serial_number, + ) + } + ) + if envoy_device is None: + _LOGGER.error( + "No envoy device found in device registry: %s %s", + DOMAIN, + self.envoy_serial_number, + ) + return + + connection = (dr.CONNECTION_NETWORK_MAC, interface.mac) + if connection in envoy_device.connections: + _LOGGER.debug( + "connection verified as existing: %s in %s", connection, self.name + ) + return + + device_registry.async_update_device( + device_id=envoy_device.id, + new_connections={connection}, + ) + _LOGGER.debug("added connection: %s to %s", connection, self.name) + @callback def _async_mark_setup_complete(self) -> None: """Mark setup as complete and setup firmware checks and token refresh if needed.""" @@ -132,6 +197,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): FIRMWARE_REFRESH_INTERVAL, cancel_on_shutdown=True, ) + self._schedule_mac_verification() self.async_cancel_token_refresh() if not isinstance(self.envoy.auth, EnvoyTokenAuth): return @@ -252,3 +318,10 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): if self._cancel_firmware_refresh: self._cancel_firmware_refresh() self._cancel_firmware_refresh = None + + @callback + def async_cancel_mac_verification(self) -> None: + """Cancel mac verification.""" + if self._cancel_mac_verification: + self._cancel_mac_verification() + self._cancel_mac_verification = None diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index d5b3880cf24..6fcf73bebe9 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -3,6 +3,7 @@ from __future__ import annotations import copy +from datetime import datetime from typing import TYPE_CHECKING, Any from attr import asdict @@ -63,19 +64,23 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]: "/ivp/ensemble/generator", "/ivp/meters", "/ivp/meters/readings", + "/home,", ] for end_point in end_points: - response = await envoy.request(end_point) - fixture_data[end_point] = response.text.replace("\n", "").replace( - serial, CLEAN_TEXT - ) - fixture_data[f"{end_point}_log"] = json_dumps( - { - "headers": dict(response.headers.items()), - "code": response.status_code, - } - ) + try: + response = await envoy.request(end_point) + fixture_data[end_point] = response.text.replace("\n", "").replace( + serial, CLEAN_TEXT + ) + fixture_data[f"{end_point}_log"] = json_dumps( + { + "headers": dict(response.headers.items()), + "code": response.status_code, + } + ) + except EnvoyError as err: + fixture_data[f"{end_point}_log"] = {"Error": repr(err)} return fixture_data @@ -143,11 +148,25 @@ async def async_get_config_entry_diagnostics( "inverters": envoy_data.inverters, "tariff": envoy_data.tariff, } + # Add Envoy active interface information to report + active_interface: dict[str, Any] = {} + if coordinator.interface: + active_interface = { + "name": (interface := coordinator.interface).primary_interface, + "interface type": interface.interface_type, + "mac": interface.mac, + "uses dhcp": interface.dhcp, + "firmware build date": datetime.fromtimestamp( + interface.software_build_epoch + ).strftime("%Y-%m-%d %H:%M:%S"), + "envoy timezone": interface.timezone, + } envoy_properties: dict[str, Any] = { "envoy_firmware": envoy.firmware, "part_number": envoy.part_number, "envoy_model": envoy.envoy_model, + "active interface": active_interface, "supported_features": [feature.name for feature in envoy.supported_features], "phase_mode": envoy.phase_mode, "phase_count": envoy.phase_count, @@ -160,10 +179,7 @@ async def async_get_config_entry_diagnostics( fixture_data: dict[str, Any] = {} if entry.options.get(OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, False): - try: - fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial) - except EnvoyError as err: - fixture_data["Error"] = repr(err) + fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial) diagnostic_data: dict[str, Any] = { "config_entry": async_redact_data(entry.as_dict(), TO_REDACT), diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 88183fe4cfd..4516a35f4fe 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,8 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.25.5"], + "quality_scale": "platinum", + "requirements": ["pyenphase==1.26.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/quality_scale.yaml b/homeassistant/components/enphase_envoy/quality_scale.yaml index 4431a298c8c..78ff6de4297 100644 --- a/homeassistant/components/enphase_envoy/quality_scale.yaml +++ b/homeassistant/components/enphase_envoy/quality_scale.yaml @@ -1,31 +1,19 @@ rules: # Bronze action-setup: - status: done + status: exempt comment: only actions implemented are platform native ones. - appropriate-polling: - status: done - comment: fixed 1 minute cycle based on Enphase Envoy device characteristics + appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy/#actions - docs-high-level-description: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy - docs-installation-instructions: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy#prerequisites - docs-removal-instructions: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy#removing-the-integration - entity-event-setup: - status: done - comment: no events used. + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done entity-unique-id: done has-entity-name: done runtime-data: done @@ -34,24 +22,14 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: todo - comment: | - needs to raise appropriate error when exception occurs. - Pending https://github.com/pyenphase/pyenphase/pull/194 + action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy#configuration - docs-installation-parameters: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy#required-manual-input + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: - status: done - comment: pending https://github.com/home-assistant/core/pull/132373 + parallel-updates: done reauthentication-flow: done test-coverage: done @@ -60,22 +38,14 @@ rules: diagnostics: done discovery-update-info: done discovery: done - docs-data-update: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy#data-updates - docs-examples: - status: todo - comment: add blue-print examples, if any - docs-known-limitations: todo - docs-supported-devices: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy#supported-devices - docs-supported-functions: todo - docs-troubleshooting: - status: done - comment: https://www.home-assistant.io/integrations/enphase_envoy#troubleshooting - docs-use-cases: todo - dynamic-devices: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -86,7 +56,7 @@ rules: repair-issues: status: exempt comment: no general issues or repair.py - stale-devices: todo + stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index ce3a8593226..e45c746869d 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -128,7 +128,7 @@ "storage_mode": { "name": "Storage mode", "state": { - "self_consumption": "Self consumption", + "self_consumption": "Self-consumption", "backup": "Full backup", "savings": "Savings mode" } @@ -393,7 +393,7 @@ }, "exceptions": { "unexpected_device": { - "message": "Unexpected Envoy serial-number found at {host}; expected {expected_serial}, found {actual_serial}" + "message": "Unexpected Envoy serial number found at {host}; expected {expected_serial}, found {actual_serial}" }, "authentication_error": { "message": "Envoy authentication failure on {host}: {args}" diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 098f231a40f..da0be245fcd 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env-canada==0.10.1"] + "requirements": ["env-canada==0.10.2"] } diff --git a/homeassistant/components/environment_canada/strings.json b/homeassistant/components/environment_canada/strings.json index 1ccff145bb3..b0b04f73879 100644 --- a/homeassistant/components/environment_canada/strings.json +++ b/homeassistant/components/environment_canada/strings.json @@ -86,7 +86,7 @@ "name": "AQHI" }, "advisories": { - "name": "Advisory" + "name": "Advisories" }, "endings": { "name": "Endings" diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py index f92be005db6..3d82cfd7511 100644 --- a/homeassistant/components/ephember/climate.py +++ b/homeassistant/components/ephember/climate.py @@ -6,13 +6,13 @@ from datetime import timedelta import logging from typing import Any -from pyephember.pyephember import ( +from pyephember2.pyephember2 import ( EphEmber, ZoneMode, zone_current_temperature, zone_is_active, zone_is_boost_active, - zone_is_hot_water, + zone_is_hotwater, zone_mode, zone_name, zone_target_temperature, @@ -69,14 +69,18 @@ def setup_platform( try: ember = EphEmber(username, password) - zones = ember.get_zones() - for zone in zones: - add_entities([EphEmberThermostat(ember, zone)]) except RuntimeError: - _LOGGER.error("Cannot connect to EphEmber") + _LOGGER.error("Cannot login to EphEmber") + + try: + homes = ember.get_zones() + except RuntimeError: + _LOGGER.error("Fail to get zones") return - return + add_entities( + EphEmberThermostat(ember, zone) for home in homes for zone in home["zones"] + ) class EphEmberThermostat(ClimateEntity): @@ -85,33 +89,36 @@ class EphEmberThermostat(ClimateEntity): _attr_hvac_modes = OPERATION_LIST _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, ember, zone): + def __init__(self, ember, zone) -> None: """Initialize the thermostat.""" self._ember = ember self._zone_name = zone_name(zone) self._zone = zone - self._hot_water = zone_is_hot_water(zone) + self._attr_unique_id = zone["zoneid"] + + # hot water = true, is immersive device without target temperature control. + self._hot_water = zone_is_hotwater(zone) self._attr_name = self._zone_name - self._attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.AUX_HEAT - ) - self._attr_target_temperature_step = 0.5 if self._hot_water: self._attr_supported_features = ClimateEntityFeature.AUX_HEAT self._attr_target_temperature_step = None - self._attr_supported_features |= ( - ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON - ) + else: + self._attr_target_temperature_step = 0.5 + self._attr_supported_features = ( + ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TARGET_TEMPERATURE + ) @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" return zone_current_temperature(self._zone) @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return zone_target_temperature(self._zone) @@ -133,12 +140,12 @@ class EphEmberThermostat(ClimateEntity): """Set the operation mode.""" mode = self.map_mode_hass_eph(hvac_mode) if mode is not None: - self._ember.set_mode_by_name(self._zone_name, mode) + self._ember.set_zone_mode(self._zone["zoneid"], mode) else: _LOGGER.error("Invalid operation mode provided %s", hvac_mode) @property - def is_aux_heat(self): + def is_aux_heat(self) -> bool: """Return true if aux heater.""" return zone_is_boost_active(self._zone) @@ -167,7 +174,7 @@ class EphEmberThermostat(ClimateEntity): if temperature > self.max_temp or temperature < self.min_temp: return - self._ember.set_target_temperture_by_name(self._zone_name, temperature) + self._ember.set_zone_target_temperature(self._zone["zoneid"], temperature) @property def min_temp(self): @@ -188,7 +195,8 @@ class EphEmberThermostat(ClimateEntity): def update(self) -> None: """Get the latest data.""" - self._zone = self._ember.get_zone(self._zone_name) + self._ember.get_zones() + self._zone = self._ember.get_zone(self._zone["zoneid"]) @staticmethod def map_mode_hass_eph(operation_mode): diff --git a/homeassistant/components/ephember/manifest.json b/homeassistant/components/ephember/manifest.json index 547ab2918f5..7d78149d068 100644 --- a/homeassistant/components/ephember/manifest.json +++ b/homeassistant/components/ephember/manifest.json @@ -1,10 +1,10 @@ { "domain": "ephember", "name": "EPH Controls", - "codeowners": ["@ttroy50"], + "codeowners": ["@ttroy50", "@roberty99"], "documentation": "https://www.home-assistant.io/integrations/ephember", "iot_class": "local_polling", - "loggers": ["pyephember"], + "loggers": ["pyephember2"], "quality_scale": "legacy", - "requirements": ["pyephember==0.3.1"] + "requirements": ["pyephember2==0.4.12"] } diff --git a/homeassistant/components/epic_games_store/strings.json b/homeassistant/components/epic_games_store/strings.json index 58a87a55f81..ab4562a72ad 100644 --- a/homeassistant/components/epic_games_store/strings.json +++ b/homeassistant/components/epic_games_store/strings.json @@ -3,8 +3,8 @@ "step": { "user": { "data": { - "language": "Language", - "country": "Country" + "language": "[%key:common::config_flow::data::language%]", + "country": "[%key:common::config_flow::data::country%]" } } }, diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index ab62c962982..1f619b2017c 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.12.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.15.1"] } diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 1e1a2763b59..f621c74642b 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from aioesphomeapi import APIClient -from homeassistant.components import ffmpeg, zeroconf +from homeassistant.components import zeroconf from homeassistant.components.bluetooth import async_remove_scanner from homeassistant.const import ( CONF_HOST, @@ -14,16 +14,14 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.helpers.typing import ConfigType -from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN -from .dashboard import async_setup as async_setup_dashboard +from . import dashboard, ffmpeg_proxy +from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN from .domain_data import DomainData - -# Import config flow so that it's added to the registry from .entry_data import ESPHomeConfigEntry, RuntimeEntryData -from .ffmpeg_proxy import FFmpegProxyData, FFmpegProxyView -from .manager import ESPHomeManager, cleanup_instance +from .manager import DEVICE_CONFLICT_ISSUE_FORMAT, ESPHomeManager, cleanup_instance CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -32,12 +30,8 @@ CLIENT_INFO = f"Home Assistant {ha_version}" async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the esphome component.""" - proxy_data = hass.data[DATA_FFMPEG_PROXY] = FFmpegProxyData() - - await async_setup_dashboard(hass) - hass.http.register_view( - FFmpegProxyView(ffmpeg.get_ffmpeg_manager(hass), proxy_data) - ) + ffmpeg_proxy.async_setup(hass) + await dashboard.async_setup(hass) return True @@ -79,7 +73,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> bool: """Unload an esphome config entry.""" - entry_data = await cleanup_instance(hass, entry) + entry_data = await cleanup_instance(entry) return await hass.config_entries.async_unload_platforms( entry, entry_data.loaded_platforms ) @@ -89,4 +83,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> """Remove an esphome config entry.""" if bluetooth_mac_address := entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS): async_remove_scanner(hass, bluetooth_mac_address.upper()) + async_delete_issue( + hass, DOMAIN, DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id) + ) await DomainData.get(hass).get_or_create_store(hass, entry).async_remove() diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py index 8f1b5ae8b1a..ad455e620bb 100644 --- a/homeassistant/components/esphome/alarm_control_panel.py +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -29,6 +29,8 @@ from .entity import ( ) from .enum_mapper import EsphomeEnumMapper +PARALLEL_UPDATES = 0 + _ESPHOME_ACP_STATE_TO_HASS_STATE: EsphomeEnumMapper[ ESPHomeAlarmControlPanelState, AlarmControlPanelState ] = EsphomeEnumMapper( @@ -48,7 +50,7 @@ _ESPHOME_ACP_STATE_TO_HASS_STATE: EsphomeEnumMapper[ class EspHomeACPFeatures(APIIntEnum): - """ESPHome AlarmCintolPanel feature numbers.""" + """ESPHome AlarmControlPanel feature numbers.""" ARM_HOME = 1 ARM_AWAY = 2 diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index a129a7723dd..073a1ec8ae9 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -35,18 +35,19 @@ from homeassistant.components.intent import ( async_register_timer_handler, ) from homeassistant.components.media_player import async_process_play_media_url -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN -from .entity import EsphomeAssistEntity -from .entry_data import ESPHomeConfigEntry, RuntimeEntryData +from .entity import EsphomeAssistEntity, convert_api_error_ha_error +from .entry_data import ESPHomeConfigEntry from .enum_mapper import EsphomeEnumMapper from .ffmpeg_proxy import async_create_proxy_url +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) _VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[ @@ -95,7 +96,7 @@ async def async_setup_entry( if entry_data.device_info.voice_assistant_feature_flags_compat( entry_data.api_version ): - async_add_entities([EsphomeAssistSatellite(entry, entry_data)]) + async_add_entities([EsphomeAssistSatellite(entry)]) class EsphomeAssistSatellite( @@ -107,17 +108,12 @@ class EsphomeAssistSatellite( key="assist_satellite", translation_key="assist_satellite" ) - def __init__( - self, - config_entry: ConfigEntry, - entry_data: RuntimeEntryData, - ) -> None: + def __init__(self, entry: ESPHomeConfigEntry) -> None: """Initialize satellite.""" - super().__init__(entry_data) + super().__init__(entry.runtime_data) - self.config_entry = config_entry - self.entry_data = entry_data - self.cli = self.entry_data.client + self.config_entry = entry + self.cli = self._entry_data.client self._is_running: bool = True self._pipeline_task: asyncio.Task | None = None @@ -133,23 +129,23 @@ class EsphomeAssistSatellite( @property def pipeline_entity_id(self) -> str | None: """Return the entity ID of the pipeline to use for the next conversation.""" - assert self.entry_data.device_info is not None + assert self._entry_data.device_info is not None ent_reg = er.async_get(self.hass) return ent_reg.async_get_entity_id( Platform.SELECT, DOMAIN, - f"{self.entry_data.device_info.mac_address}-pipeline", + f"{self._entry_data.device_info.mac_address}-pipeline", ) @property def vad_sensitivity_entity_id(self) -> str | None: """Return the entity ID of the VAD sensitivity to use for the next conversation.""" - assert self.entry_data.device_info is not None + assert self._entry_data.device_info is not None ent_reg = er.async_get(self.hass) return ent_reg.async_get_entity_id( Platform.SELECT, DOMAIN, - f"{self.entry_data.device_info.mac_address}-vad_sensitivity", + f"{self._entry_data.device_info.mac_address}-vad_sensitivity", ) @callback @@ -195,16 +191,16 @@ class EsphomeAssistSatellite( _LOGGER.debug("Received satellite configuration: %s", self._satellite_config) # Inform listeners that config has been updated - self.entry_data.async_assist_satellite_config_updated(self._satellite_config) + self._entry_data.async_assist_satellite_config_updated(self._satellite_config) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() - assert self.entry_data.device_info is not None + assert self._entry_data.device_info is not None feature_flags = ( - self.entry_data.device_info.voice_assistant_feature_flags_compat( - self.entry_data.api_version + self._entry_data.device_info.voice_assistant_feature_flags_compat( + self._entry_data.api_version ) ) if feature_flags & VoiceAssistantFeature.API_AUDIO: @@ -260,7 +256,7 @@ class EsphomeAssistSatellite( # Update wake word select when config is updated self.async_on_remove( - self.entry_data.async_register_assist_satellite_set_wake_word_callback( + self._entry_data.async_register_assist_satellite_set_wake_word_callback( self.async_set_wake_word ) ) @@ -282,7 +278,7 @@ class EsphomeAssistSatellite( data_to_send: dict[str, Any] = {} if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: - self.entry_data.async_set_assist_pipeline_state(True) + self._entry_data.async_set_assist_pipeline_state(True) elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: assert event.data is not None data_to_send = {"text": event.data["stt_output"]["text"]} @@ -304,18 +300,19 @@ class EsphomeAssistSatellite( url = async_process_play_media_url(self.hass, path) data_to_send = {"url": url} - assert self.entry_data.device_info is not None + assert self._entry_data.device_info is not None feature_flags = ( - self.entry_data.device_info.voice_assistant_feature_flags_compat( - self.entry_data.api_version + self._entry_data.device_info.voice_assistant_feature_flags_compat( + self._entry_data.api_version ) ) - if feature_flags & VoiceAssistantFeature.SPEAKER: - media_id = tts_output["media_id"] + if feature_flags & VoiceAssistantFeature.SPEAKER and ( + stream := tts.async_get_stream(self.hass, tts_output["token"]) + ): self._tts_streaming_task = ( self.config_entry.async_create_background_task( self.hass, - self._stream_tts_audio(media_id), + self._stream_tts_audio(stream), "esphome_voice_assistant_tts", ) ) @@ -333,13 +330,20 @@ class EsphomeAssistSatellite( "code": event.data["code"], "message": event.data["message"], } + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: + assert event.data is not None + if tts_output := event.data["tts_output"]: + path = tts_output["url"] + url = async_process_play_media_url(self.hass, path) + data_to_send = {"url": url} elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: if self._tts_streaming_task is None: # No TTS - self.entry_data.async_set_assist_pipeline_state(False) + self._entry_data.async_set_assist_pipeline_state(False) self.cli.send_voice_assistant_event(event_type, data_to_send) + @convert_api_error_ha_error async def async_announce( self, announcement: assist_satellite.AssistSatelliteAnnouncement ) -> None: @@ -349,6 +353,7 @@ class EsphomeAssistSatellite( """ await self._do_announce(announcement, run_pipeline_after=False) + @convert_api_error_ha_error async def async_start_conversation( self, start_announcement: assist_satellite.AssistSatelliteAnnouncement ) -> None: @@ -376,7 +381,7 @@ class EsphomeAssistSatellite( # Route media through the proxy format_to_use: MediaPlayerSupportedFormat | None = None for supported_format in chain( - *self.entry_data.media_player_formats.values() + *self._entry_data.media_player_formats.values() ): if supported_format.purpose == MediaPlayerFormatPurpose.ANNOUNCEMENT: format_to_use = supported_format @@ -434,10 +439,10 @@ class EsphomeAssistSatellite( # API or UDP output audio port: int = 0 - assert self.entry_data.device_info is not None + assert self._entry_data.device_info is not None feature_flags = ( - self.entry_data.device_info.voice_assistant_feature_flags_compat( - self.entry_data.api_version + self._entry_data.device_info.voice_assistant_feature_flags_compat( + self._entry_data.api_version ) ) if (feature_flags & VoiceAssistantFeature.SPEAKER) and not ( @@ -538,7 +543,7 @@ class EsphomeAssistSatellite( def _update_tts_format(self) -> None: """Update the TTS format from the first media player.""" - for supported_format in chain(*self.entry_data.media_player_formats.values()): + for supported_format in chain(*self._entry_data.media_player_formats.values()): # Find first announcement format if supported_format.purpose == MediaPlayerFormatPurpose.ANNOUNCEMENT: self._attr_tts_options = { @@ -564,7 +569,7 @@ class EsphomeAssistSatellite( async def _stream_tts_audio( self, - media_id: str, + tts_result: tts.ResultStream, sample_rate: int = 16000, sample_width: int = 2, sample_channels: int = 1, @@ -579,15 +584,14 @@ class EsphomeAssistSatellite( if not self._is_running: return - extension, data = await tts.async_get_media_source_audio( - self.hass, - media_id, - ) - - if extension != "wav": - _LOGGER.error("Only WAV audio can be streamed, got %s", extension) + if tts_result.extension != "wav": + _LOGGER.error( + "Only WAV audio can be streamed, got %s", tts_result.extension + ) return + data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: if ( (wav_file.getframerate() != sample_rate) @@ -625,7 +629,7 @@ class EsphomeAssistSatellite( # State change self.tts_response_finished() - self.entry_data.async_set_assist_pipeline_state(False) + self._entry_data.async_set_assist_pipeline_state(False) async def _wrap_audio_stream(self) -> AsyncIterable[bytes]: """Yield audio chunks from the queue until None.""" diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 02b13748fb6..deccb6cc7da 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -2,46 +2,20 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from functools import partial from aioesphomeapi import BinarySensorInfo, BinarySensorState, EntityInfo from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, - BinarySensorEntityDescription, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.core import callback from homeassistant.util.enum import try_parse_enum -from .const import DOMAIN -from .entity import EsphomeAssistEntity, EsphomeEntity, platform_async_setup_entry -from .entry_data import ESPHomeConfigEntry +from .entity import EsphomeEntity, platform_async_setup_entry - -async def async_setup_entry( - hass: HomeAssistant, - entry: ESPHomeConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up ESPHome binary sensors based on a config entry.""" - await platform_async_setup_entry( - hass, - entry, - async_add_entities, - info_type=BinarySensorInfo, - entity_type=EsphomeBinarySensor, - state_type=BinarySensorState, - ) - - entry_data = entry.runtime_data - assert entry_data.device_info is not None - if entry_data.device_info.voice_assistant_feature_flags_compat( - entry_data.api_version - ): - async_add_entities([EsphomeAssistInProgressBinarySensor(entry_data)]) +PARALLEL_UPDATES = 0 class EsphomeBinarySensor( @@ -74,50 +48,9 @@ class EsphomeBinarySensor( return self._static_info.is_status_binary_sensor or super().available -class EsphomeAssistInProgressBinarySensor(EsphomeAssistEntity, BinarySensorEntity): - """A binary sensor implementation for ESPHome for use with assist_pipeline.""" - - entity_description = BinarySensorEntityDescription( - entity_registry_enabled_default=False, - key="assist_in_progress", - translation_key="assist_in_progress", - ) - - async def async_added_to_hass(self) -> None: - """Create issue.""" - await super().async_added_to_hass() - if TYPE_CHECKING: - assert self.registry_entry is not None - ir.async_create_issue( - self.hass, - DOMAIN, - f"assist_in_progress_deprecated_{self.registry_entry.id}", - breaks_in_ha_version="2025.4", - data={ - "entity_id": self.entity_id, - "entity_uuid": self.registry_entry.id, - "integration_name": "ESPHome", - }, - is_fixable=True, - severity=ir.IssueSeverity.WARNING, - translation_key="assist_in_progress_deprecated", - translation_placeholders={ - "integration_name": "ESPHome", - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Remove issue.""" - await super().async_will_remove_from_hass() - if TYPE_CHECKING: - assert self.registry_entry is not None - ir.async_delete_issue( - self.hass, - DOMAIN, - f"assist_in_progress_deprecated_{self.registry_entry.id}", - ) - - @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - return self._entry_data.assist_pipeline_state +async_setup_entry = partial( + platform_async_setup_entry, + info_type=BinarySensorInfo, + entity_type=EsphomeBinarySensor, + state_type=BinarySensorState, +) diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py index f13fa65ede1..31121d98ff7 100644 --- a/homeassistant/components/esphome/button.py +++ b/homeassistant/components/esphome/button.py @@ -16,6 +16,8 @@ from .entity import ( platform_async_setup_entry, ) +PARALLEL_UPDATES = 0 + class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity): """A button implementation for ESPHome.""" diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index 6038bf52e06..e2213153092 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -16,6 +16,8 @@ from homeassistant.core import callback from .entity import EsphomeEntity, platform_async_setup_entry +PARALLEL_UPDATES = 0 + class EsphomeCamera(Camera, EsphomeEntity[CameraInfo, CameraState]): """A camera implementation for ESPHome.""" diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index b651f16dfd7..667d5d00154 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -65,6 +65,8 @@ from .entity import ( ) from .enum_mapper import EsphomeEnumMapper +PARALLEL_UPDATES = 0 + FAN_QUIET = "quiet" @@ -178,13 +180,13 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti def _get_precision(self) -> float: """Return the precision of the climate device.""" - precicions = [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] + precisions = [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] static_info = self._static_info if static_info.visual_current_temperature_step != 0: step = static_info.visual_current_temperature_step else: step = static_info.visual_target_temperature_step - for prec in precicions: + for prec in precisions: if step >= prec: return prec # Fall back to highest precision, tenths diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 686d77d9b34..75408246e78 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -22,7 +22,9 @@ import voluptuous as vol from homeassistant.components import zeroconf from homeassistant.config_entries import ( + SOURCE_IGNORE, SOURCE_REAUTH, + SOURCE_RECONFIGURE, ConfigEntry, ConfigFlow, ConfigFlowResult, @@ -30,6 +32,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo @@ -44,16 +47,19 @@ from .const import ( CONF_SUBSCRIBE_LOGS, DEFAULT_ALLOW_SERVICE_CALLS, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + DEFAULT_PORT, DOMAIN, ) from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info +from .entry_data import ESPHomeConfigEntry +from .manager import async_replace_device ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key" ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk" -ESPHOME_URL = "https://esphome.io/" _LOGGER = logging.getLogger(__name__) ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" +DEFAULT_NAME = "ESPHome" class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): @@ -62,6 +68,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 _reauth_entry: ConfigEntry + _reconfig_entry: ConfigEntry def __init__(self) -> None: """Initialize flow.""" @@ -74,6 +81,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._device_info: DeviceInfo | None = None # The ESPHome name as per its config self._device_name: str | None = None + self._device_mac: str | None = None + self._entry_with_name_conflict: ConfigEntry | None = None async def _async_step_user_base( self, user_input: dict[str, Any] | None = None, error: str | None = None @@ -85,7 +94,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): fields: dict[Any, type] = OrderedDict() fields[vol.Required(CONF_HOST, default=self._host or vol.UNDEFINED)] = str - fields[vol.Optional(CONF_PORT, default=self._port or 6053)] = int + fields[vol.Optional(CONF_PORT, default=self._port or DEFAULT_PORT)] = int errors = {} if error is not None: @@ -95,7 +104,6 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema(fields), errors=errors, - description_placeholders={"esphome_url": ESPHOME_URL}, ) async def async_step_user( @@ -112,8 +120,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._host = entry_data[CONF_HOST] self._port = entry_data[CONF_PORT] self._password = entry_data[CONF_PASSWORD] - self._name = self._reauth_entry.title self._device_name = entry_data.get(CONF_DEVICE_NAME) + self._name = self._reauth_entry.title # Device without encryption allows fetching device info. We can then check # if the device is no longer using a password. If we did try with a password, @@ -138,11 +146,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle reauthorization flow when encryption was removed.""" if user_input is not None: self._noise_psk = None - return self._async_get_entry() + return await self._async_validated_connection() return self.async_show_form( step_id="reauth_encryption_removed_confirm", - description_placeholders={"name": self._name}, + description_placeholders={"name": self._async_get_human_readable_name()}, ) async def async_step_reauth_confirm( @@ -167,17 +175,31 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}), errors=errors, - description_placeholders={"name": self._name}, + description_placeholders={"name": self._async_get_human_readable_name()}, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by a reconfig request.""" + self._reconfig_entry = self._get_reconfigure_entry() + data = self._reconfig_entry.data + self._host = data[CONF_HOST] + self._port = data.get(CONF_PORT, DEFAULT_PORT) + self._noise_psk = data.get(CONF_NOISE_PSK) + self._device_name = data.get(CONF_DEVICE_NAME) + return await self._async_step_user_base() + @property def _name(self) -> str: - return self.__name or "ESPHome" + return self.__name or DEFAULT_NAME @_name.setter def _name(self, value: str) -> None: self.__name = value - self.context["title_placeholders"] = {"name": self._name} + self.context["title_placeholders"] = { + "name": self._async_get_human_readable_name() + } async def _async_try_fetch_device_info(self) -> ConfigFlowResult: """Try to fetch device info and return any errors.""" @@ -228,7 +250,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_authenticate() self._password = "" - return self._async_get_entry() + return await self._async_validated_connection() async def async_step_discovery_confirm( self, user_input: dict[str, Any] | None = None @@ -237,7 +259,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: return await self._async_try_fetch_device_info() return self.async_show_form( - step_id="discovery_confirm", description_placeholders={"name": self._name} + step_id="discovery_confirm", + description_placeholders={"name": self._async_get_human_readable_name()}, ) async def async_step_zeroconf( @@ -257,20 +280,75 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): # Hostname is format: livingroom.local. device_name = discovery_info.hostname.removesuffix(".local.") - self._name = discovery_info.properties.get("friendly_name", device_name) self._device_name = device_name + self._name = discovery_info.properties.get("friendly_name", device_name) self._host = discovery_info.host self._port = discovery_info.port self._noise_required = bool(discovery_info.properties.get("api_encryption")) # Check if already configured await self.async_set_unique_id(mac_address) - self._abort_if_unique_id_configured( - updates={CONF_HOST: self._host, CONF_PORT: self._port} + await self._async_validate_mac_abort_configured( + mac_address, self._host, self._port ) - return await self.async_step_discovery_confirm() + async def _async_validate_mac_abort_configured( + self, formatted_mac: str, host: str, port: int | None + ) -> None: + """Validate if the MAC address is already configured.""" + assert self.unique_id is not None + if not ( + entry := self.hass.config_entries.async_entry_for_domain_unique_id( + self.handler, formatted_mac + ) + ): + return + if entry.source == SOURCE_IGNORE: + # Don't call _fetch_device_info() for ignored entries + raise AbortFlow("already_configured") + configured_host: str | None = entry.data.get(CONF_HOST) + configured_port: int | None = entry.data.get(CONF_PORT) + if configured_host == host and configured_port == port: + # Don't probe to verify the mac is correct since + # the host and port matches. + raise AbortFlow("already_configured") + configured_psk: str | None = entry.data.get(CONF_NOISE_PSK) + await self._fetch_device_info(host, port or configured_port, configured_psk) + updates: dict[str, Any] = {} + if self._device_mac == formatted_mac: + updates[CONF_HOST] = host + if port is not None: + updates[CONF_PORT] = port + self._abort_unique_id_configured_with_details(updates=updates) + + @callback + def _abort_unique_id_configured_with_details(self, updates: dict[str, Any]) -> None: + """Abort if unique_id is already configured with details.""" + assert self.unique_id is not None + if not ( + conflict_entry := self.hass.config_entries.async_entry_for_domain_unique_id( + self.handler, self.unique_id + ) + ): + return + assert conflict_entry.unique_id is not None + if self.source == SOURCE_RECONFIGURE: + error = "reconfigure_already_configured" + elif updates: + error = "already_configured_updates" + else: + error = "already_configured_detailed" + self._abort_if_unique_id_configured( + updates=updates, + error=error, + description_placeholders={ + "title": conflict_entry.title, + "name": conflict_entry.data.get(CONF_DEVICE_NAME, "unknown"), + "mac": format_mac(conflict_entry.unique_id), + }, + ) + async def async_step_mqtt( self, discovery_info: MqttServiceInfo ) -> ConfigFlowResult: @@ -304,7 +382,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): # Check if already configured await self.async_set_unique_id(mac_address) - self._abort_if_unique_id_configured( + self._abort_unique_id_configured_with_details( updates={CONF_HOST: self._host, CONF_PORT: self._port} ) @@ -314,8 +392,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" - await self.async_set_unique_id(format_mac(discovery_info.macaddress)) - self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + mac_address = format_mac(discovery_info.macaddress) + await self.async_set_unique_id(format_mac(mac_address)) + await self._async_validate_mac_abort_configured( + mac_address, discovery_info.ip, None + ) # This should never happen since we only listen to DHCP requests # for configured devices. return self.async_abort(reason="already_configured") @@ -332,9 +413,84 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="service_received") + async def async_step_name_conflict( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle name conflict resolution.""" + assert self._entry_with_name_conflict is not None + assert self._entry_with_name_conflict.unique_id is not None + assert self.unique_id is not None + assert self._device_name is not None + return self.async_show_menu( + step_id="name_conflict", + menu_options=["name_conflict_migrate", "name_conflict_overwrite"], + description_placeholders={ + "existing_mac": format_mac(self._entry_with_name_conflict.unique_id), + "existing_title": self._entry_with_name_conflict.title, + "mac": format_mac(self.unique_id), + "name": self._device_name, + }, + ) + + async def async_step_name_conflict_migrate( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle migration of existing entry.""" + assert self._entry_with_name_conflict is not None + assert self._entry_with_name_conflict.unique_id is not None + assert self.unique_id is not None + assert self._device_name is not None + assert self._host is not None + old_mac = format_mac(self._entry_with_name_conflict.unique_id) + new_mac = format_mac(self.unique_id) + entry_id = self._entry_with_name_conflict.entry_id + self.hass.config_entries.async_update_entry( + self._entry_with_name_conflict, + data={ + **self._entry_with_name_conflict.data, + CONF_HOST: self._host, + CONF_PORT: self._port or DEFAULT_PORT, + CONF_PASSWORD: self._password or "", + CONF_NOISE_PSK: self._noise_psk or "", + }, + ) + await async_replace_device(self.hass, entry_id, old_mac, new_mac) + self.hass.config_entries.async_schedule_reload(entry_id) + return self.async_abort( + reason="name_conflict_migrated", + description_placeholders={ + "existing_mac": old_mac, + "mac": new_mac, + "name": self._device_name, + }, + ) + + async def async_step_name_conflict_overwrite( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle creating a new entry by removing the old one and creating new.""" + assert self._entry_with_name_conflict is not None + await self.hass.config_entries.async_remove( + self._entry_with_name_conflict.entry_id + ) + return self._async_create_entry() + @callback - def _async_get_entry(self) -> ConfigFlowResult: - config_data = { + def _async_create_entry(self) -> ConfigFlowResult: + """Create the config entry.""" + assert self._name is not None + return self.async_create_entry( + title=self._name, + data=self._async_make_config_data(), + options={ + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + }, + ) + + @callback + def _async_make_config_data(self) -> dict[str, Any]: + """Return config data for the entry.""" + return { CONF_HOST: self._host, CONF_PORT: self._port, # The API uses protobuf, so empty string denotes absence @@ -342,19 +498,99 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): CONF_NOISE_PSK: self._noise_psk or "", CONF_DEVICE_NAME: self._device_name, } - config_options = { - CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, - } - if self.source == SOURCE_REAUTH: - return self.async_update_reload_and_abort( - self._reauth_entry, data=self._reauth_entry.data | config_data - ) - assert self._name is not None - return self.async_create_entry( - title=self._name, - data=config_data, - options=config_options, + async def _async_validated_connection(self) -> ConfigFlowResult: + """Handle validated connection.""" + if self.source == SOURCE_RECONFIGURE: + return await self._async_reconfig_validated_connection() + if self.source == SOURCE_REAUTH: + return await self._async_reauth_validated_connection() + for entry in self._async_current_entries(include_ignore=False): + if entry.data.get(CONF_DEVICE_NAME) == self._device_name: + self._entry_with_name_conflict = entry + return await self.async_step_name_conflict() + return self._async_create_entry() + + async def _async_reauth_validated_connection(self) -> ConfigFlowResult: + """Handle reauth validated connection.""" + assert self._reauth_entry.unique_id is not None + if self.unique_id == self._reauth_entry.unique_id: + return self.async_update_reload_and_abort( + self._reauth_entry, + data=self._reauth_entry.data | self._async_make_config_data(), + ) + assert self._host is not None + self._abort_unique_id_configured_with_details( + updates={ + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_NOISE_PSK: self._noise_psk, + } + ) + # Reauth was triggered a while ago, and since than + # a new device resides at the same IP address. + assert self._device_name is not None + return self.async_abort( + reason="reauth_unique_id_changed", + description_placeholders={ + "name": self._reauth_entry.data.get( + CONF_DEVICE_NAME, self._reauth_entry.title + ), + "host": self._host, + "expected_mac": format_mac(self._reauth_entry.unique_id), + "unexpected_mac": format_mac(self.unique_id), + "unexpected_device_name": self._device_name, + }, + ) + + async def _async_reconfig_validated_connection(self) -> ConfigFlowResult: + """Handle reconfigure validated connection.""" + assert self._reconfig_entry.unique_id is not None + assert self._host is not None + assert self._device_name is not None + if not ( + unique_id_matches := (self.unique_id == self._reconfig_entry.unique_id) + ): + self._abort_unique_id_configured_with_details( + updates={ + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_NOISE_PSK: self._noise_psk, + } + ) + for entry in self._async_current_entries(include_ignore=False): + if ( + entry.entry_id != self._reconfig_entry.entry_id + and entry.data.get(CONF_DEVICE_NAME) == self._device_name + ): + return self.async_abort( + reason="reconfigure_name_conflict", + description_placeholders={ + "name": self._reconfig_entry.data[CONF_DEVICE_NAME], + "host": self._host, + "expected_mac": format_mac(self._reconfig_entry.unique_id), + "existing_title": entry.title, + }, + ) + if unique_id_matches: + return self.async_update_reload_and_abort( + self._reconfig_entry, + data=self._reconfig_entry.data | self._async_make_config_data(), + ) + if self._reconfig_entry.data.get(CONF_DEVICE_NAME) == self._device_name: + self._entry_with_name_conflict = self._reconfig_entry + return await self.async_step_name_conflict() + return self.async_abort( + reason="reconfigure_unique_id_changed", + description_placeholders={ + "name": self._reconfig_entry.data.get( + CONF_DEVICE_NAME, self._reconfig_entry.title + ), + "host": self._host, + "expected_mac": format_mac(self._reconfig_entry.unique_id), + "unexpected_mac": format_mac(self.unique_id), + "unexpected_device_name": self._device_name, + }, ) async def async_step_encryption_key( @@ -373,9 +609,30 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): step_id="encryption_key", data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}), errors=errors, - description_placeholders={"name": self._name}, + description_placeholders={"name": self._async_get_human_readable_name()}, ) + @callback + def _async_get_human_readable_name(self) -> str: + """Return a human readable name for the entry.""" + entry: ConfigEntry | None = None + if self.source == SOURCE_REAUTH: + entry = self._reauth_entry + elif self.source == SOURCE_RECONFIGURE: + entry = self._reconfig_entry + friendly_name = self._name + device_name = self._device_name + if ( + device_name + and friendly_name in (DEFAULT_NAME, device_name) + and entry + and entry.title != friendly_name + ): + friendly_name = entry.title + if not device_name or friendly_name == device_name: + return friendly_name + return f"{friendly_name} ({device_name})" + async def async_step_authenticate( self, user_input: dict[str, Any] | None = None, error: str | None = None ) -> ConfigFlowResult: @@ -385,7 +642,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): error = await self.try_login() if error: return await self.async_step_authenticate(error=error) - return self._async_get_entry() + return await self._async_validated_connection() errors = {} if error is not None: @@ -394,23 +651,22 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="authenticate", data_schema=vol.Schema({vol.Required("password"): str}), - description_placeholders={"name": self._name}, + description_placeholders={"name": self._async_get_human_readable_name()}, errors=errors, ) - async def fetch_device_info(self) -> str | None: + async def _fetch_device_info( + self, host: str, port: int | None, noise_psk: str | None + ) -> str | None: """Fetch device info from API and return any errors.""" zeroconf_instance = await zeroconf.async_get_instance(self.hass) - assert self._host is not None - assert self._port is not None cli = APIClient( - self._host, - self._port, + host, + port or DEFAULT_PORT, "", zeroconf_instance=zeroconf_instance, - noise_psk=self._noise_psk, + noise_psk=noise_psk, ) - try: await cli.connect() self._device_info = await cli.device_info() @@ -418,8 +674,12 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return ERROR_REQUIRES_ENCRYPTION_KEY except InvalidEncryptionKeyAPIError as ex: if ex.received_name: + device_name_changed = self._device_name != ex.received_name self._device_name = ex.received_name - self._name = ex.received_name + if ex.received_mac: + self._device_mac = format_mac(ex.received_mac) + if not self._name or device_name_changed: + self._name = ex.received_name return ERROR_INVALID_ENCRYPTION_KEY except ResolveAPIError: return "resolve_error" @@ -427,14 +687,29 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return "connection_error" finally: await cli.disconnect(force=True) - - self._name = self._device_info.friendly_name or self._device_info.name + self._device_mac = format_mac(self._device_info.mac_address) self._device_name = self._device_info.name + self._name = self._device_info.friendly_name or self._device_info.name + return None + + async def fetch_device_info(self) -> str | None: + """Fetch device info from API and return any errors.""" + assert self._host is not None + assert self._port is not None + if error := await self._fetch_device_info( + self._host, self._port, self._noise_psk + ): + return error + assert self._device_info is not None mac_address = format_mac(self._device_info.mac_address) await self.async_set_unique_id(mac_address, raise_on_progress=False) - if self.source != SOURCE_REAUTH: - self._abort_if_unique_id_configured( - updates={CONF_HOST: self._host, CONF_PORT: self._port} + if self.source not in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + self._abort_unique_id_configured_with_details( + updates={ + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_NOISE_PSK: self._noise_psk, + } ) return None @@ -500,7 +775,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: ESPHomeConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index c7cd7fdcdf0..f793fd16bfe 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -1,5 +1,7 @@ """ESPHome constants.""" +from typing import Final + from awesomeversion import AwesomeVersion DOMAIN = "esphome" @@ -13,6 +15,7 @@ CONF_BLUETOOTH_MAC_ADDRESS = "bluetooth_mac_address" DEFAULT_ALLOW_SERVICE_CALLS = True DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False +DEFAULT_PORT: Final = 6053 STABLE_BLE_VERSION_STR = "2025.2.2" STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) @@ -22,5 +25,3 @@ PROJECT_URLS = { # ESPHome always uses .0 for the changelog URL STABLE_BLE_URL_VERSION = f"{STABLE_BLE_VERSION.major}.{STABLE_BLE_VERSION.minor}.0" DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html" - -DATA_FFMPEG_PROXY = f"{DOMAIN}.ffmpeg_proxy" diff --git a/homeassistant/components/esphome/coordinator.py b/homeassistant/components/esphome/coordinator.py index b31a74dcf3f..99ae6d38a9d 100644 --- a/homeassistant/components/esphome/coordinator.py +++ b/homeassistant/components/esphome/coordinator.py @@ -5,43 +5,38 @@ from __future__ import annotations from datetime import timedelta import logging -import aiohttp from awesomeversion import AwesomeVersion from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) MIN_VERSION_SUPPORTS_UPDATE = AwesomeVersion("2023.1.0") +REFRESH_INTERVAL = timedelta(minutes=5) class ESPHomeDashboardCoordinator(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): """Class to interact with the ESPHome dashboard.""" - def __init__( - self, - hass: HomeAssistant, - addon_slug: str, - url: str, - session: aiohttp.ClientSession, - ) -> None: - """Initialize.""" + def __init__(self, hass: HomeAssistant, addon_slug: str, url: str) -> None: + """Initialize the dashboard coordinator.""" super().__init__( hass, _LOGGER, config_entry=None, name="ESPHome Dashboard", - update_interval=timedelta(minutes=5), + update_interval=REFRESH_INTERVAL, always_update=False, ) self.addon_slug = addon_slug self.url = url - self.api = ESPHomeDashboardAPI(url, session) + self.api = ESPHomeDashboardAPI(url, async_get_clientsession(hass)) self.supports_update: bool | None = None - async def _async_update_data(self) -> dict: + async def _async_update_data(self) -> dict[str, ConfiguredDevice]: """Fetch device data.""" devices = await self.api.get_devices() configured_devices = devices["configured"] diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 83c749f89ca..4426724e3f4 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -24,6 +24,8 @@ from .entity import ( platform_async_setup_entry, ) +PARALLEL_UPDATES = 0 + class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): """A cover implementation for ESPHome.""" diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 290feec1e2a..5f879edf005 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store from homeassistant.util.hass_dict import HassKey @@ -60,11 +60,26 @@ class ESPHomeDashboardManager: async def async_setup(self) -> None: """Restore the dashboard from storage.""" self._data = await self._store.async_load() - if (data := self._data) and (info := data.get("info")): - await self.async_set_dashboard_info( - info["addon_slug"], info["host"], info["port"] + if not (data := self._data) or not (info := data.get("info")): + return + if is_hassio(self._hass): + from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel + get_addons_info, ) + if (addons := get_addons_info(self._hass)) is not None and info[ + "addon_slug" + ] not in addons: + # The addon is not installed anymore, but it make come back + # so we don't want to remove the dashboard, but for now + # we don't want to use it. + _LOGGER.debug("Addon %s is no longer installed", info["addon_slug"]) + return + + await self.async_set_dashboard_info( + info["addon_slug"], info["host"], info["port"] + ) + @callback def async_get(self) -> ESPHomeDashboardCoordinator | None: """Get the current dashboard.""" @@ -88,9 +103,7 @@ class ESPHomeDashboardManager: self._cancel_shutdown = None self._current_dashboard = None - dashboard = ESPHomeDashboardCoordinator( - hass, addon_slug, url, async_get_clientsession(hass) - ) + dashboard = ESPHomeDashboardCoordinator(hass, addon_slug, url) await dashboard.async_request_refresh() self._current_dashboard = dashboard diff --git a/homeassistant/components/esphome/date.py b/homeassistant/components/esphome/date.py index 28bc532918a..ef446cceac6 100644 --- a/homeassistant/components/esphome/date.py +++ b/homeassistant/components/esphome/date.py @@ -11,6 +11,8 @@ from homeassistant.components.date import DateEntity from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +PARALLEL_UPDATES = 0 + class EsphomeDate(EsphomeEntity[DateInfo, DateState], DateEntity): """A date implementation for esphome.""" diff --git a/homeassistant/components/esphome/datetime.py b/homeassistant/components/esphome/datetime.py index d1bb0bb77ff..3ea285fa849 100644 --- a/homeassistant/components/esphome/datetime.py +++ b/homeassistant/components/esphome/datetime.py @@ -12,6 +12,8 @@ from homeassistant.util import dt as dt_util from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +PARALLEL_UPDATES = 0 + class EsphomeDateTime(EsphomeEntity[DateTimeInfo, DateTimeState], DateTimeEntity): """A datetime implementation for esphome.""" diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index 0903e874a15..c59fca26b90 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -10,10 +10,18 @@ from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant from . import CONF_NOISE_PSK +from .const import CONF_DEVICE_NAME from .dashboard import async_get_dashboard from .entry_data import ESPHomeConfigEntry REDACT_KEYS = {CONF_NOISE_PSK, CONF_PASSWORD, "mac_address", "bluetooth_mac_address"} +CONFIGURED_DEVICE_KEYS = ( + "configuration", + "current_version", + "deployed_version", + "loaded_integrations", + "target_platform", +) async def async_get_config_entry_diagnostics( @@ -26,6 +34,9 @@ async def async_get_config_entry_diagnostics( entry_data = config_entry.runtime_data device_info = entry_data.device_info + device_name: str | None = ( + device_info.name if device_info else config_entry.data.get(CONF_DEVICE_NAME) + ) if (storage_data := await entry_data.store.async_load()) is not None: diag["storage_data"] = storage_data @@ -45,7 +56,19 @@ async def async_get_config_entry_diagnostics( "scanner": await scanner.async_diagnostics(), } + diag_dashboard: dict[str, Any] = {"configured": False} + diag["dashboard"] = diag_dashboard if dashboard := async_get_dashboard(hass): - diag["dashboard"] = dashboard.addon_slug + diag_dashboard["configured"] = True + diag_dashboard["supports_update"] = dashboard.supports_update + diag_dashboard["last_update_success"] = dashboard.last_update_success + diag_dashboard["last_exception"] = dashboard.last_exception + diag_dashboard["addon"] = dashboard.addon_slug + if device_name and dashboard.data: + diag_dashboard["has_matching_name"] = device_name in dashboard.data + if data := dashboard.data.get(device_name): + diag_dashboard["device"] = { + key: data.get(key) for key in CONFIGURED_DEVICE_KEYS + } return async_redact_data(diag, REDACT_KEYS) diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py index ed307b46fd6..2a323d47a06 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -17,15 +17,12 @@ STORAGE_VERSION = 1 @dataclass(slots=True) class DomainData: - """Define a class that stores global esphome data in hass.data[DOMAIN].""" + """Define a class that stores global esphome data.""" _stores: dict[str, ESPHomeStorage] = field(default_factory=dict) def get_entry_data(self, entry: ESPHomeConfigEntry) -> RuntimeEntryData: - """Return the runtime entry data associated with this config entry. - - Raises KeyError if the entry isn't loaded yet. - """ + """Return the runtime entry data associated with this config entry.""" return entry.runtime_data def get_or_create_store( diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index ff08e5f578a..7b02680afee 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Concatenate, Generic, TypeVar, cast from aioesphomeapi import ( APIConnectionError, + DeviceInfo as EsphomeDeviceInfo, EntityCategory as EsphomeEntityCategory, EntityInfo, EntityState, @@ -28,6 +29,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .const import DOMAIN + # Import config flow so that it's added to the registry from .entry_data import ESPHomeConfigEntry, RuntimeEntryData from .enum_mapper import EsphomeEnumMapper @@ -153,7 +156,7 @@ def esphome_float_state_property[_EntityT: EsphomeEntity[Any, Any]]( return _wrapper -def convert_api_error_ha_error[**_P, _R, _EntityT: EsphomeEntity[Any, Any]]( +def convert_api_error_ha_error[**_P, _R, _EntityT: EsphomeBaseEntity]( func: Callable[Concatenate[_EntityT, _P], Awaitable[None]], ) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: """Decorate ESPHome command calls that send commands/make changes to the device. @@ -167,7 +170,12 @@ def convert_api_error_ha_error[**_P, _R, _EntityT: EsphomeEntity[Any, Any]]( return await func(self, *args, **kwargs) except APIConnectionError as error: raise HomeAssistantError( - f"Error communicating with device: {error}" + translation_domain=DOMAIN, + translation_key="error_communicating_with_device", + translation_placeholders={ + "device_name": self._device_info.name, + "error": str(error), + }, ) from error return handler @@ -187,10 +195,18 @@ ENTITY_CATEGORIES: EsphomeEnumMapper[EsphomeEntityCategory, EntityCategory | Non ) -class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): +class EsphomeBaseEntity(Entity): """Define a base esphome entity.""" + _attr_has_entity_name = True _attr_should_poll = False + _device_info: EsphomeDeviceInfo + device_entry: dr.DeviceEntry + + +class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): + """Define an esphome entity.""" + _static_info: _InfoT _state: _StateT _has_state: bool @@ -215,25 +231,16 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} ) - # - # If `friendly_name` is set, we use the Friendly naming rules, if - # `friendly_name` is not set we make an exception to the naming rules for - # backwards compatibility and use the Legacy naming rules. - # - # Friendly naming - # - Friendly name is prepended to entity names - # - Device Name is prepended to entity ids - # - Entity id is constructed from device name and object id - # - # Legacy naming - # - Device name is not prepended to entity names - # - Device name is not prepended to entity ids - # - Entity id is constructed from entity name - # - if not device_info.friendly_name: - return - self._attr_has_entity_name = True - self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}" + if entity_info.name: + self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}" + else: + # https://github.com/home-assistant/core/issues/132532 + # If name is not set, ESPHome will use the sanitized friendly name + # as the name, however we want to use the original object_id + # as the entity_id before it is sanitized since the sanitizer + # is not utf-8 aware. In this case, its always going to be + # an empty string so we drop the object_id. + self.entity_id = f"{domain}.{device_info.name}" async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -269,7 +276,12 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self._static_info = static_info self._attr_unique_id = build_unique_id(device_info.mac_address, static_info) self._attr_entity_registry_enabled_default = not static_info.disabled_by_default - self._attr_name = static_info.name + # https://github.com/home-assistant/core/issues/132532 + # If the name is "", we need to set it to None since otherwise + # the friendly_name will be "{friendly_name} " with a trailing + # space. ESPHome uses protobuf under the hood, and an empty field + # gets a default value of "". + self._attr_name = static_info.name if static_info.name else None if entity_category := static_info.entity_category: self._attr_entity_category = ENTITY_CATEGORIES.from_esphome(entity_category) else: @@ -320,15 +332,12 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self.async_write_ha_state() -class EsphomeAssistEntity(Entity): +class EsphomeAssistEntity(EsphomeBaseEntity): """Define a base entity for Assist Pipeline entities.""" - _attr_has_entity_name = True - _attr_should_poll = False - def __init__(self, entry_data: RuntimeEntryData) -> None: """Initialize the binary sensor.""" - self._entry_data: RuntimeEntryData = entry_data + self._entry_data = entry_data assert entry_data.device_info is not None device_info = entry_data.device_info self._device_info = device_info diff --git a/homeassistant/components/esphome/event.py b/homeassistant/components/esphome/event.py index f4db3844e3d..4437292c5b4 100644 --- a/homeassistant/components/esphome/event.py +++ b/homeassistant/components/esphome/event.py @@ -12,6 +12,8 @@ from homeassistant.util.enum import try_parse_enum from .entity import EsphomeEntity, platform_async_setup_entry +PARALLEL_UPDATES = 0 + class EsphomeEvent(EsphomeEntity[EventInfo, Event], EventEntity): """An event implementation for ESPHome.""" diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index c09145c17b5..7cdc3570d61 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -30,6 +30,8 @@ from .entity import ( ) from .enum_mapper import EsphomeEnumMapper +PARALLEL_UPDATES = 0 + ORDERED_NAMED_FAN_SPEEDS = [FanSpeed.LOW, FanSpeed.MEDIUM, FanSpeed.HIGH] @@ -104,7 +106,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): @property @esphome_state_property - def is_on(self) -> bool | None: + def is_on(self) -> bool: """Return true if the entity is on.""" return self._state.state @@ -124,7 +126,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): @property @esphome_state_property - def oscillating(self) -> bool | None: + def oscillating(self) -> bool: """Return the oscillation state.""" return self._state.oscillating @@ -136,7 +138,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): @property @esphome_state_property - def preset_mode(self) -> str | None: + def preset_mode(self) -> str: """Return the current fan preset mode.""" return self._state.preset_mode diff --git a/homeassistant/components/esphome/ffmpeg_proxy.py b/homeassistant/components/esphome/ffmpeg_proxy.py index 9484d1e7593..b57a6762148 100644 --- a/homeassistant/components/esphome/ffmpeg_proxy.py +++ b/homeassistant/components/esphome/ffmpeg_proxy.py @@ -11,17 +11,20 @@ from typing import Final from aiohttp import web from aiohttp.abc import AbstractStreamWriter, BaseRequest +from homeassistant.components import ffmpeg from homeassistant.components.ffmpeg import FFmpegManager from homeassistant.components.http import HomeAssistantView -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.util.hass_dict import HassKey -from .const import DATA_FFMPEG_PROXY +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) _MAX_CONVERSIONS_PER_DEVICE: Final[int] = 2 +@callback def async_create_proxy_url( hass: HomeAssistant, device_id: str, @@ -32,7 +35,7 @@ def async_create_proxy_url( width: int | None = None, ) -> str: """Create a use proxy URL that automatically converts the media.""" - data: FFmpegProxyData = hass.data[DATA_FFMPEG_PROXY] + data = hass.data[DATA_FFMPEG_PROXY] return data.async_create_proxy_url( device_id, media_url, media_format, rate, channels, width ) @@ -313,3 +316,16 @@ class FFmpegProxyView(HomeAssistantView): assert writer is not None await resp.transcode(request, writer) return resp + + +DATA_FFMPEG_PROXY: HassKey[FFmpegProxyData] = HassKey(f"{DOMAIN}.ffmpeg_proxy") + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the ffmpeg proxy.""" + proxy_data = FFmpegProxyData() + hass.data[DATA_FFMPEG_PROXY] = proxy_data + hass.http.register_view( + FFmpegProxyView(ffmpeg.get_ffmpeg_manager(hass), proxy_data) + ) diff --git a/homeassistant/components/esphome/icons.json b/homeassistant/components/esphome/icons.json new file mode 100644 index 00000000000..fc0595b028e --- /dev/null +++ b/homeassistant/components/esphome/icons.json @@ -0,0 +1,20 @@ +{ + "entity": { + "binary_sensor": { + "assist_in_progress": { + "default": "mdi:timer-sand" + } + }, + "select": { + "pipeline": { + "default": "mdi:filter-outline" + }, + "vad_sensitivity": { + "default": "mdi:volume-high" + }, + "wake_word": { + "default": "mdi:microphone" + } + } + } +} diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 8fecf34862b..d8d827f18a1 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -3,6 +3,7 @@ from __future__ import annotations from functools import lru_cache, partial +from operator import methodcaller from typing import TYPE_CHECKING, Any, cast from aioesphomeapi import ( @@ -38,6 +39,8 @@ from .entity import ( platform_async_setup_entry, ) +PARALLEL_UPDATES = 0 + FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10} @@ -106,7 +109,7 @@ def _mired_to_kelvin(mired_temperature: float) -> int: def _color_mode_to_ha(mode: int) -> str: """Convert an esphome color mode to a HA color mode constant. - Choses the color mode that best matches the feature-set. + Choose the color mode that best matches the feature-set. """ candidates = [] for ha_mode, cap_lists in _COLOR_MODE_MAPPING.items(): @@ -146,7 +149,7 @@ def _least_complex_color_mode(color_modes: tuple[int, ...]) -> int: # popcount with bin() function because it appears # to be the best way: https://stackoverflow.com/a/9831671 color_modes_list = list(color_modes) - color_modes_list.sort(key=lambda mode: (mode).bit_count()) + color_modes_list.sort(key=methodcaller("bit_count")) return color_modes_list[0] @@ -158,7 +161,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @property @esphome_state_property - def is_on(self) -> bool | None: + def is_on(self) -> bool: """Return true if the light is on.""" return self._state.state @@ -290,13 +293,13 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @property @esphome_state_property - def brightness(self) -> int | None: + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return round(self._state.brightness * 255) @property @esphome_state_property - def color_mode(self) -> str | None: + def color_mode(self) -> str: """Return the color mode of the light.""" if not self._supports_color_mode: supported_color_modes = self.supported_color_modes @@ -308,7 +311,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @property @esphome_state_property - def rgb_color(self) -> tuple[int, int, int] | None: + def rgb_color(self) -> tuple[int, int, int]: """Return the rgb color value [int, int, int].""" state = self._state if not self._supports_color_mode: @@ -326,7 +329,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @property @esphome_state_property - def rgbw_color(self) -> tuple[int, int, int, int] | None: + def rgbw_color(self) -> tuple[int, int, int, int]: """Return the rgbw color value [int, int, int, int].""" white = round(self._state.white * 255) rgb = cast("tuple[int, int, int]", self.rgb_color) @@ -334,7 +337,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @property @esphome_state_property - def rgbww_color(self) -> tuple[int, int, int, int, int] | None: + def rgbww_color(self) -> tuple[int, int, int, int, int]: """Return the rgbww color value [int, int, int, int, int].""" state = self._state rgb = cast("tuple[int, int, int]", self.rgb_color) @@ -370,7 +373,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @property @esphome_state_property - def effect(self) -> str | None: + def effect(self) -> str: """Return the current effect.""" return self._state.effect diff --git a/homeassistant/components/esphome/lock.py b/homeassistant/components/esphome/lock.py index 502cd361277..cfb9af614dd 100644 --- a/homeassistant/components/esphome/lock.py +++ b/homeassistant/components/esphome/lock.py @@ -18,6 +18,8 @@ from .entity import ( platform_async_setup_entry, ) +PARALLEL_UPDATES = 0 + class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity): """A lock implementation for ESPHome.""" @@ -38,25 +40,25 @@ class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity): @property @esphome_state_property - def is_locked(self) -> bool | None: + def is_locked(self) -> bool: """Return true if the lock is locked.""" return self._state.state is LockState.LOCKED @property @esphome_state_property - def is_locking(self) -> bool | None: + def is_locking(self) -> bool: """Return true if the lock is locking.""" return self._state.state is LockState.LOCKING @property @esphome_state_property - def is_unlocking(self) -> bool | None: + def is_unlocking(self) -> bool: """Return true if the lock is unlocking.""" return self._state.state is LockState.UNLOCKING @property @esphome_state_property - def is_jammed(self) -> bool | None: + def is_jammed(self) -> bool: """Return true if the lock is jammed (incomplete locking).""" return self._state.state is LockState.JAMMED diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 56c2998a3cc..1b0e4fc8986 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -44,10 +44,12 @@ from homeassistant.core import ( State, callback, ) -from homeassistant.exceptions import TemplateError +from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import ( config_validation as cv, device_registry as dr, + entity_registry as er, + issue_registry as ir, template, ) from homeassistant.helpers.device_registry import format_mac @@ -80,6 +82,8 @@ from .domain_data import DomainData # Import config flow so that it's added to the registry from .entry_data import ESPHomeConfigEntry, RuntimeEntryData +DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}" + if TYPE_CHECKING: from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] SubscribeLogsResponse, @@ -212,7 +216,7 @@ class ESPHomeManager: async def on_stop(self, event: Event) -> None: """Cleanup the socket client on HA close.""" - await cleanup_instance(self.hass, self.entry) + await cleanup_instance(self.entry) @property def services_issue(self) -> str: @@ -373,7 +377,7 @@ class ESPHomeManager: async def on_connect(self) -> None: """Subscribe to states and list entities on successful API login.""" try: - await self._on_connnect() + await self._on_connect() except APIConnectionError as err: _LOGGER.warning( "Error getting setting up connection for %s: %s", self.host, err @@ -409,7 +413,7 @@ class ESPHomeManager: self._async_on_log, self._log_level ) - async def _on_connnect(self) -> None: + async def _on_connect(self) -> None: """Subscribe to states and list entities on successful API login.""" entry = self.entry unique_id = entry.unique_id @@ -418,7 +422,7 @@ class ESPHomeManager: assert reconnect_logic is not None, "Reconnect logic must be set" hass = self.hass cli = self.cli - stored_device_name = entry.data.get(CONF_DEVICE_NAME) + stored_device_name: str | None = entry.data.get(CONF_DEVICE_NAME) unique_id_is_mac_address = unique_id and ":" in unique_id if entry.options.get(CONF_SUBSCRIBE_LOGS): self._async_subscribe_logs(self._async_get_equivalent_log_level()) @@ -448,12 +452,36 @@ class ESPHomeManager: if not mac_address_matches and not unique_id_is_mac_address: hass.config_entries.async_update_entry(entry, unique_id=device_mac) + issue = DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id) if not mac_address_matches and unique_id_is_mac_address: # If the unique id is a mac address # and does not match we have the wrong device and we need # to abort the connection. This can happen if the DHCP # server changes the IP address of the device and we end up # connecting to the wrong device. + if stored_device_name == device_info.name: + # If the device name matches it might be a device replacement + # or they made a mistake and flashed the same firmware on + # multiple devices. In this case we start a repair flow + # to ask them if its a mistake, or if they want to migrate + # the config entry to the replacement hardware. + shared_data = { + "name": device_info.name, + "mac": format_mac(device_mac), + "stored_mac": format_mac(unique_id), + "model": device_info.model, + "ip": self.host, + } + async_create_issue( + hass, + DOMAIN, + issue, + is_fixable=True, + severity=IssueSeverity.ERROR, + translation_key="device_conflict", + translation_placeholders=shared_data, + data={**shared_data, "entry_id": entry.entry_id}, + ) _LOGGER.error( "Unexpected device found at %s; " "expected `%s` with mac address `%s`, " @@ -475,6 +503,7 @@ class ESPHomeManager: # flow. return + async_delete_issue(hass, DOMAIN, issue) # Make sure we have the correct device name stored # so we can map the device to ESPHome Dashboard config # If we got here, we know the mac address matches or we @@ -492,6 +521,15 @@ class ESPHomeManager: if device_info.name: reconnect_logic.name = device_info.name + if not device_info.friendly_name: + _LOGGER.info( + "No `friendly_name` set in the `esphome:` section of the " + "YAML config for device '%s' (MAC: %s); It's recommended " + "to add one for easier identification and better alignment " + "with Home Assistant naming conventions", + device_info.name, + device_mac, + ) self.device_id = _async_setup_device_registry(hass, entry, entry_data) entry_data.async_update_device_state() @@ -568,7 +606,7 @@ class ESPHomeManager: async def on_connect_error(self, err: Exception) -> None: """Start reauth flow if appropriate connect error type.""" - if isinstance( + if not isinstance( err, ( EncryptionPlaintextAPIError, @@ -577,7 +615,36 @@ class ESPHomeManager: InvalidAuthAPIError, ), ): - self.entry.async_start_reauth(self.hass) + return + if isinstance(err, InvalidEncryptionKeyAPIError): + if ( + (received_name := err.received_name) + and (received_mac := err.received_mac) + and (unique_id := self.entry.unique_id) + and ":" in unique_id + ): + formatted_received_mac = format_mac(received_mac) + formatted_expected_mac = format_mac(unique_id) + if formatted_received_mac != formatted_expected_mac: + _LOGGER.error( + "Unexpected device found at %s; " + "expected `%s` with mac address `%s`, " + "found `%s` with mac address `%s`", + self.host, + self.entry.data.get(CONF_DEVICE_NAME), + formatted_expected_mac, + received_name, + formatted_received_mac, + ) + # If the device comes back online, discovery + # will update the config entry with the new IP address + # and reload which will try again to connect to the device. + # In the mean time we stop the reconnect logic + # so we don't keep trying to connect to the wrong device. + if self.reconnect_logic: + await self.reconnect_logic.stop() + return + self.entry.async_start_reauth(self.hass) @callback def _async_handle_logging_changed(self, _event: Event) -> None: @@ -588,6 +655,30 @@ class ESPHomeManager: ): self._async_subscribe_logs(new_log_level) + @callback + def _async_cleanup(self) -> None: + """Cleanup stale issues and entities.""" + assert self.entry_data.device_info is not None + ent_reg = er.async_get(self.hass) + # Cleanup stale assist_in_progress entity and issue, + # Remove this after 2026.4 + if not ( + stale_entry_entity_id := ent_reg.async_get_entity_id( + DOMAIN, + Platform.BINARY_SENSOR, + f"{self.entry_data.device_info.mac_address}-assist_in_progress", + ) + ): + return + stale_entry = ent_reg.async_get(stale_entry_entity_id) + assert stale_entry is not None + ent_reg.async_remove(stale_entry_entity_id) + issue_reg = ir.async_get(self.hass) + if issue := issue_reg.async_get_issue( + DOMAIN, f"assist_in_progress_deprecated_{stale_entry.id}" + ): + issue_reg.async_delete(DOMAIN, issue.issue_id) + async def async_start(self) -> None: """Start the esphome connection manager.""" hass = self.hass @@ -630,6 +721,7 @@ class ESPHomeManager: _setup_services(hass, entry_data, services) if (device_info := entry_data.device_info) is not None: + self._async_cleanup() if device_info.name: reconnect_logic.name = device_info.name if ( @@ -699,7 +791,7 @@ def _async_setup_device_registry( config_entry_id=entry.entry_id, configuration_url=configuration_url, connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}, - name=entry_data.friendly_name, + name=entry_data.friendly_name or entry_data.name, manufacturer=manufacturer, model=model, sw_version=sw_version, @@ -770,7 +862,18 @@ def execute_service( entry_data: RuntimeEntryData, service: UserService, call: ServiceCall ) -> None: """Execute a service on a node.""" - entry_data.client.execute_service(service, call.data) + try: + entry_data.client.execute_service(service, call.data) + except APIConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="action_call_failed", + translation_placeholders={ + "call_name": service.name, + "device_name": entry_data.name, + "error": str(err), + }, + ) from err def build_service_name(device_info: EsphomeDeviceInfo, service: UserService) -> str: @@ -862,9 +965,7 @@ def _setup_services( _async_register_service(hass, entry_data, device_info, service) -async def cleanup_instance( - hass: HomeAssistant, entry: ESPHomeConfigEntry -) -> RuntimeEntryData: +async def cleanup_instance(entry: ESPHomeConfigEntry) -> RuntimeEntryData: """Cleanup the esphome client if it exists.""" data = entry.runtime_data data.async_on_disconnect() @@ -873,3 +974,40 @@ async def cleanup_instance( await data.async_cleanup() await data.client.disconnect() return data + + +async def async_replace_device( + hass: HomeAssistant, + entry_id: str, + old_mac: str, # will be lower case (format_mac) + new_mac: str, # will be lower case (format_mac) +) -> None: + """Migrate an ESPHome entry to replace an existing device.""" + entry = hass.config_entries.async_get_entry(entry_id) + assert entry is not None + hass.config_entries.async_update_entry(entry, unique_id=new_mac) + + dev_reg = dr.async_get(hass) + for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id): + dev_reg.async_update_device( + device.id, + new_connections={(dr.CONNECTION_NETWORK_MAC, new_mac)}, + ) + + ent_reg = er.async_get(hass) + upper_mac = new_mac.upper() + old_upper_mac = old_mac.upper() + for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id): + # -- + old_unique_id = entity.unique_id.split("-") + new_unique_id = "-".join([upper_mac, *old_unique_id[1:]]) + if entity.unique_id != new_unique_id and entity.unique_id.startswith( + old_upper_mac + ): + ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id) + + domain_data = DomainData.get(hass) + store = domain_data.get_or_create_store(hass, entry) + if data := await store.async_load(): + data["device_info"]["mac_address"] = upper_mac + await store.async_save(data) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 31ed27c02ae..beaf68decd9 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -1,7 +1,7 @@ { "domain": "esphome", "name": "ESPHome", - "after_dependencies": ["zeroconf", "tag"], + "after_dependencies": ["hassio", "zeroconf", "tag"], "codeowners": ["@OttoWinter", "@jesserockz", "@kbx81", "@bdraco"], "config_flow": true, "dependencies": ["assist_pipeline", "bluetooth", "intent", "ffmpeg", "http"], @@ -15,10 +15,11 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], + "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==29.9.0", - "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.12.0" + "aioesphomeapi==30.1.0", + "esphome-dashboard-api==1.3.0", + "bleak-esphome==2.15.1" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index 8a30814aa2c..3af6c0b2049 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -41,6 +41,8 @@ from .entity import ( from .enum_mapper import EsphomeEnumMapper from .ffmpeg_proxy import async_create_proxy_url +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) _STATES: EsphomeEnumMapper[EspMediaPlayerState, MediaPlayerState] = EsphomeEnumMapper( @@ -94,7 +96,7 @@ class EsphomeMediaPlayer( @property @esphome_float_state_property - def volume_level(self) -> float | None: + def volume_level(self) -> float: """Volume level of the media player (0..1).""" return self._state.volume @@ -146,10 +148,6 @@ class EsphomeMediaPlayer( announcement: bool, ) -> str | None: """Get URL for ffmpeg proxy.""" - if self.device_entry is None: - # Device id is required - return None - # Choose the first default or announcement supported format format_to_use: MediaPlayerSupportedFormat | None = None for supported_format in supported_formats: diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index 2d74dad1bcf..4a6800e1041 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -23,6 +23,8 @@ from .entity import ( ) from .enum_mapper import EsphomeEnumMapper +PARALLEL_UPDATES = 0 + NUMBER_MODES: EsphomeEnumMapper[EsphomeNumberMode, NumberMode] = EsphomeEnumMapper( { EsphomeNumberMode.AUTO: NumberMode.AUTO, diff --git a/homeassistant/components/esphome/quality_scale.yaml b/homeassistant/components/esphome/quality_scale.yaml new file mode 100644 index 00000000000..9af63cfbb3e --- /dev/null +++ b/homeassistant/components/esphome/quality_scale.yaml @@ -0,0 +1,85 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + Since actions are defined per device, rather than per integration, + they are specific to the device's YAML configuration. Additionally, + ESPHome allows for user-defined actions, making it impossible to + set them up until the device is connected as they vary by device. For more + information, see: https://esphome.io/components/api.html#user-defined-actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + Since actions are defined per device, rather than per integration, + they are specific to the device's YAML configuration. Additionally, + ESPHome allows for user-defined actions, making it difficult to provide + standard documentation since these actions vary by device. For more + information, see: https://esphome.io/components/api.html#user-defined-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: + status: exempt + comment: | + ESPHome relies on sleepy devices and fast reconnect logic, so we + can't raise `ConfigEntryNotReady`. Instead, we need to utilize the + reconnect logic in `aioesphomeapi` to determine the right moment + to trigger the connection. + unique-config-entry: done + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: + status: exempt + comment: | + Since ESPHome is a framework for creating custom devices, the + possibilities are virtually limitless. As a result, example + automations would likely only be relevant to the specific user + of the device and not generally useful to others. + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: done + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/esphome/repairs.py b/homeassistant/components/esphome/repairs.py index 31e4b88c689..3cba8730cd6 100644 --- a/homeassistant/components/esphome/repairs.py +++ b/homeassistant/components/esphome/repairs.py @@ -2,11 +2,92 @@ from __future__ import annotations -from homeassistant.components.assist_pipeline.repair_flows import ( - AssistInProgressDeprecatedRepairFlow, -) +from typing import cast + +import voluptuous as vol + +from homeassistant import data_entry_flow from homeassistant.components.repairs import RepairsFlow -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +from .manager import async_replace_device + + +class ESPHomeRepair(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, data: dict[str, str | int | float | None] | None) -> None: + """Initialize.""" + self._data = data + super().__init__() + + @callback + def _async_get_placeholders(self) -> dict[str, str]: + issue_registry = ir.async_get(self.hass) + issue = issue_registry.async_get_issue(self.handler, self.issue_id) + assert issue is not None + return issue.translation_placeholders or {} + + +class DeviceConflictRepair(ESPHomeRepair): + """Handler for an issue fixing device conflict.""" + + @property + def entry_id(self) -> str: + """Return the config entry id.""" + assert isinstance(self._data, dict) + return cast(str, self._data["entry_id"]) + + @property + def mac(self) -> str: + """Return the MAC address of the new device.""" + assert isinstance(self._data, dict) + return cast(str, self._data["mac"]) + + @property + def stored_mac(self) -> str: + """Return the MAC address of the stored device.""" + assert isinstance(self._data, dict) + return cast(str, self._data["stored_mac"]) + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return self.async_show_menu( + step_id="init", + menu_options=["migrate", "manual"], + description_placeholders=self._async_get_placeholders(), + ) + + async def async_step_migrate( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the migrate step of a fix flow.""" + if user_input is None: + return self.async_show_form( + step_id="migrate", + data_schema=vol.Schema({}), + description_placeholders=self._async_get_placeholders(), + ) + entry_id = self.entry_id + await async_replace_device(self.hass, entry_id, self.stored_mac, self.mac) + self.hass.config_entries.async_schedule_reload(entry_id) + return self.async_create_entry(data={}) + + async def async_step_manual( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the manual step of a fix flow.""" + if user_input is None: + return self.async_show_form( + step_id="manual", + data_schema=vol.Schema({}), + description_placeholders=self._async_get_placeholders(), + ) + self.hass.config_entries.async_schedule_reload(self.entry_id) + return self.async_create_entry(data={}) async def async_create_fix_flow( @@ -15,8 +96,8 @@ async def async_create_fix_flow( data: dict[str, str | int | float | None] | None, ) -> RepairsFlow: """Create flow.""" - if issue_id.startswith("assist_in_progress_deprecated"): - return AssistInProgressDeprecatedRepairFlow(data) + if issue_id.startswith("device_conflict"): + return DeviceConflictRepair(data) # If ESPHome adds confirm-only repairs in the future, this should be changed # to return a ConfirmRepairFlow instead of raising a ValueError raise ValueError(f"unknown repair {issue_id}") diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index 67bcbbbd221..d5451f69f0f 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -25,6 +25,8 @@ from .entity import ( ) from .entry_data import ESPHomeConfigEntry, RuntimeEntryData +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -50,7 +52,7 @@ async def async_setup_entry( [ EsphomeAssistPipelineSelect(hass, entry_data), EsphomeVadSensitivitySelect(hass, entry_data), - EsphomeAssistSatelliteWakeWordSelect(hass, entry_data), + EsphomeAssistSatelliteWakeWordSelect(entry_data), ] ) @@ -105,11 +107,10 @@ class EsphomeAssistSatelliteWakeWordSelect( translation_key="wake_word", entity_category=EntityCategory.CONFIG, ) - _attr_should_poll = False _attr_current_option: str | None = None _attr_options: list[str] = [] - def __init__(self, hass: HomeAssistant, entry_data: RuntimeEntryData) -> None: + def __init__(self, entry_data: RuntimeEntryData) -> None: """Initialize a wake word selector.""" EsphomeAssistEntity.__init__(self, entry_data) diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 26f33f4fb47..611d7056ff7 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -20,19 +20,21 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum from .entity import EsphomeEntity, platform_async_setup_entry +from .entry_data import ESPHomeConfigEntry from .enum_mapper import EsphomeEnumMapper +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ESPHomeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up esphome sensors based on a config entry.""" diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 437b9ac2098..bc198d514ab 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -2,6 +2,9 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_configured_detailed": "A device `{name}`, with MAC address `{mac}` is already configured as `{title}`.", + "already_configured_updates": "A device `{name}`, with MAC address `{mac}` is already configured as `{title}`; the existing configuration will be updated with the validated data.", + "reconfigure_already_configured": "A device `{name}` with MAC address `{mac}` is already configured as `{title}`. Reconfiguration was aborted because the new configuration appears to refer to a different device.", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "mdns_missing_mac": "Missing MAC address in mDNS properties.", @@ -9,13 +12,19 @@ "mqtt_missing_mac": "Missing MAC address in MQTT properties.", "mqtt_missing_api": "Missing API port in MQTT properties.", "mqtt_missing_ip": "Missing IP address in MQTT properties.", - "mqtt_missing_payload": "Missing MQTT Payload." + "mqtt_missing_payload": "Missing MQTT Payload.", + "name_conflict_migrated": "The configuration for `{name}` has been migrated to a new device with MAC address `{mac}` from `{existing_mac}`.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "reauth_unique_id_changed": "**Re-authentication of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`).", + "reconfigure_name_conflict": "**Reconfiguration of `{name}` was aborted** because the address `{host}` points to a device named `{name}` (MAC: `{expected_mac}`), which is already in use by another configuration entry: `{existing_title}`.", + "reconfigure_unique_id_changed": "**Reconfiguration of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`)." }, "error": { - "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address", - "connection_error": "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.", + "resolve_error": "Unable to resolve the address of the ESPHome device. If this issue continues, consider setting a static IP address.", + "connection_error": "Unable to connect to the ESPHome device. Make sure the device’s YAML configuration includes an `api` section.", + "requires_encryption_key": "The ESPHome device requires an encryption key. Enter the key defined in the device’s YAML configuration under `api -> encryption -> key`.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_psk": "The transport encryption key is invalid. Please ensure it matches what you have in your configuration" + "invalid_psk": "The encryption key is invalid. Make sure it matches the value in the device’s YAML configuration under `api -> encryption -> key`." }, "step": { "user": { @@ -23,32 +32,53 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, - "description": "Please enter connection settings of your [ESPHome]({esphome_url}) node." + "data_description": { + "host": "IP address or hostname of the ESPHome device", + "port": "Port that the native API is running on" + }, + "description": "Please enter connection settings of your ESPHome device." }, "authenticate": { "data": { "password": "[%key:common::config_flow::data::password%]" }, - "description": "Please enter the password you set in your configuration for {name}." + "data_description": { + "password": "Passwords are deprecated and will be removed in a future version. Please update your ESPHome device YAML configuration to use an encryption key instead." + }, + "description": "Please enter the password you set in your ESPHome device YAML configuration for `{name}`." }, "encryption_key": { "data": { "noise_psk": "Encryption key" }, - "description": "Please enter the encryption key for {name}. You can find it in the ESPHome Dashboard or in your device configuration." + "data_description": { + "noise_psk": "The encryption key is used to encrypt the connection between Home Assistant and the ESPHome device. You can find this in the api: section of your ESPHome device YAML configuration." + }, + "description": "Please enter the encryption key for `{name}`. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration." }, "reauth_confirm": { "data": { "noise_psk": "[%key:component::esphome::config::step::encryption_key::data::noise_psk%]" }, - "description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your device configuration." + "data_description": { + "noise_psk": "[%key:component::esphome::config::step::encryption_key::data_description::noise_psk%]" + }, + "description": "The ESPHome device `{name}` enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration." }, "reauth_encryption_removed_confirm": { - "description": "The ESPHome device {name} disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections." + "description": "The ESPHome device `{name}` disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections." }, "discovery_confirm": { - "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?", - "title": "Discovered ESPHome node" + "description": "Do you want to add the device `{name}` to Home Assistant?", + "title": "Discovered ESPHome device" + }, + "name_conflict": { + "title": "Name conflict", + "description": "**The name `{name}` is already being used by another device: {existing_title} (MAC address: `{existing_mac}`)**\n\nTo continue, please choose one of the following options:\n\n**Migrate configuration to new device:** If this is a replacement, migrate the existing settings to the new device (`{mac}`).\n**Overwrite the existing configuration:** If this is not a replacement, delete the old configuration for `{existing_mac}` and use the new device instead.", + "menu_options": { + "name_conflict_migrate": "Migrate configuration to new device", + "name_conflict_overwrite": "Overwrite the existing configuration" + } } }, "flow_title": "{name}" @@ -58,7 +88,11 @@ "init": { "data": { "allow_service_calls": "Allow the device to perform Home Assistant actions.", - "subscribe_logs": "Subscribe to logs from the device. When enabled, the device will send logs to Home Assistant and you can view them in the logs panel." + "subscribe_logs": "Subscribe to logs from the device." + }, + "data_description": { + "allow_service_calls": "When enabled, ESPHome devices can perform Home Assistant actions, such as calling services or sending events. Only enable this if you trust the device.", + "subscribe_logs": "When enabled, the device will send logs to Home Assistant and you can view them in the logs panel." } } } @@ -69,11 +103,6 @@ "name": "[%key:component::assist_satellite::entity_component::_::name%]" } }, - "binary_sensor": { - "assist_in_progress": { - "name": "[%key:component::assist_pipeline::entity::binary_sensor::assist_in_progress::name%]" - } - }, "select": { "pipeline": { "name": "[%key:component::assist_pipeline::entity::select::pipeline::name%]", @@ -130,6 +159,43 @@ "service_calls_not_allowed": { "title": "{name} is not permitted to perform Home Assistant actions", "description": "The ESPHome device attempted to perform a Home Assistant action, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to perform Home Assistant action, you can enable this functionality in the options flow." + }, + "device_conflict": { + "title": "Device conflict for {name}", + "fix_flow": { + "step": { + "init": { + "title": "Device conflict for {name}", + "description": "**The device `{name}` (`{model}`) at `{ip}` has reported a MAC address change from `{stored_mac}` to `{mac}`.**\n\nIf you have multiple devices with the same name, please rename or remove the one with MAC address `{mac}` to avoid conflicts.\n\nIf this is a hardware replacement, please confirm that you would like to migrate the Home Assistant configuration to the new device with MAC address `{mac}`.", + "menu_options": { + "migrate": "Migrate configuration to new device", + "manual": "Remove or rename device" + } + }, + "migrate": { + "title": "Confirm device replacement for {name}", + "description": "Are you sure you want to migrate the Home Assistant configuration for `{name}` (`{model}`) at `{ip}` from `{stored_mac}` to `{mac}`?" + }, + "manual": { + "title": "Remove or rename device {name}", + "description": "To resolve the conflict, either remove the device with MAC address `{mac}` from the network and restart the one with MAC address `{stored_mac}`, or re-flash the device with MAC address `{mac}` using a different name than `{name}`. Submit again once done." + } + } + } + } + }, + "exceptions": { + "action_call_failed": { + "message": "Failed to execute the action call {call_name} on {device_name}: {error}" + }, + "error_communicating_with_device": { + "message": "Error communicating with the device {device_name}: {error}" + }, + "error_compiling": { + "message": "Error compiling {configuration}; Try again in ESPHome dashboard for more information." + }, + "error_uploading": { + "message": "Error during OTA of {configuration}; Try again in ESPHome dashboard for more information." } } } diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index c210ae1440b..35edbf678ad 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -18,6 +18,8 @@ from .entity import ( platform_async_setup_entry, ) +PARALLEL_UPDATES = 0 + class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity): """A switch implementation for ESPHome.""" @@ -34,7 +36,7 @@ class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity): @property @esphome_state_property - def is_on(self) -> bool | None: + def is_on(self) -> bool: """Return true if the switch is on.""" return self._state.state diff --git a/homeassistant/components/esphome/text.py b/homeassistant/components/esphome/text.py index 36d77aac4a0..c36621b8f4e 100644 --- a/homeassistant/components/esphome/text.py +++ b/homeassistant/components/esphome/text.py @@ -17,6 +17,8 @@ from .entity import ( ) from .enum_mapper import EsphomeEnumMapper +PARALLEL_UPDATES = 0 + TEXT_MODES: EsphomeEnumMapper[EsphomeTextMode, TextMode] = EsphomeEnumMapper( { EsphomeTextMode.TEXT: TextMode.TEXT, diff --git a/homeassistant/components/esphome/time.py b/homeassistant/components/esphome/time.py index 477c47cf636..b0e586e1792 100644 --- a/homeassistant/components/esphome/time.py +++ b/homeassistant/components/esphome/time.py @@ -11,6 +11,8 @@ from homeassistant.components.time import TimeEntity from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +PARALLEL_UPDATES = 0 + class EsphomeTime(EsphomeEntity[TimeInfo, TimeState], TimeEntity): """A time implementation for esphome.""" diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index 60d4989063b..01ac638bdb1 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -18,7 +18,6 @@ from homeassistant.components.update import ( UpdateEntity, UpdateEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr @@ -27,16 +26,18 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.enum import try_parse_enum +from .const import DOMAIN from .coordinator import ESPHomeDashboardCoordinator from .dashboard import async_get_dashboard -from .domain_data import DomainData from .entity import ( EsphomeEntity, convert_api_error_ha_error, esphome_state_property, platform_async_setup_entry, ) -from .entry_data import RuntimeEntryData +from .entry_data import ESPHomeConfigEntry, RuntimeEntryData + +PARALLEL_UPDATES = 0 KEY_UPDATE_LOCK = "esphome_update_lock" @@ -45,7 +46,7 @@ NO_FEATURES = UpdateEntityFeature(0) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ESPHomeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ESPHome update based on a config entry.""" @@ -60,7 +61,7 @@ async def async_setup_entry( if (dashboard := async_get_dashboard(hass)) is None: return - entry_data = DomainData.get(hass).get_entry_data(entry) + entry_data = entry.runtime_data assert entry_data.device_info is not None device_name = entry_data.device_info.name unsubs: list[CALLBACK_TYPE] = [] @@ -68,7 +69,6 @@ async def async_setup_entry( @callback def _async_setup_update_entity() -> None: """Set up the update entity.""" - nonlocal unsubs assert dashboard is not None # Keep listening until device is available if not entry_data.available or not dashboard.last_update_success: @@ -93,10 +93,12 @@ async def async_setup_entry( _async_setup_update_entity() return - unsubs = [ - entry_data.async_subscribe_device_updated(_async_setup_update_entity), - dashboard.async_add_listener(_async_setup_update_entity), - ] + unsubs.extend( + [ + entry_data.async_subscribe_device_updated(_async_setup_update_entity), + dashboard.async_add_listener(_async_setup_update_entity), + ] + ) class ESPHomeDashboardUpdateEntity( @@ -107,7 +109,6 @@ class ESPHomeDashboardUpdateEntity( _attr_has_entity_name = True _attr_device_class = UpdateDeviceClass.FIRMWARE _attr_title = "ESPHome" - _attr_name = "Firmware" _attr_release_url = "https://esphome.io/changelog/" _attr_entity_registry_enabled_default = False @@ -200,16 +201,23 @@ class ESPHomeDashboardUpdateEntity( api = coordinator.api device = coordinator.data.get(self._device_info.name) assert device is not None + configuration = device["configuration"] try: - if not await api.compile(device["configuration"]): + if not await api.compile(configuration): raise HomeAssistantError( - f"Error compiling {device['configuration']}; " - "Try again in ESPHome dashboard for more information." + translation_domain=DOMAIN, + translation_key="error_compiling", + translation_placeholders={ + "configuration": configuration, + }, ) - if not await api.upload(device["configuration"], "OTA"): + if not await api.upload(configuration, "OTA"): raise HomeAssistantError( - f"Error updating {device['configuration']} via OTA; " - "Try again in ESPHome dashboard for more information." + translation_domain=DOMAIN, + translation_key="error_uploading", + translation_placeholders={ + "configuration": configuration, + }, ) finally: await self.coordinator.async_request_refresh() @@ -233,7 +241,7 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity): @property @esphome_state_property - def installed_version(self) -> str | None: + def installed_version(self) -> str: """Return the installed version.""" return self._state.current_version @@ -251,19 +259,19 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity): @property @esphome_state_property - def release_summary(self) -> str | None: + def release_summary(self) -> str: """Return the release summary.""" return self._state.release_summary @property @esphome_state_property - def release_url(self) -> str | None: + def release_url(self) -> str: """Return the release URL.""" return self._state.release_url @property @esphome_state_property - def title(self) -> str | None: + def title(self) -> str: """Return the title of the update.""" return self._state.title diff --git a/homeassistant/components/esphome/valve.py b/homeassistant/components/esphome/valve.py index d779a6abb9f..f71a253c1f1 100644 --- a/homeassistant/components/esphome/valve.py +++ b/homeassistant/components/esphome/valve.py @@ -22,6 +22,8 @@ from .entity import ( platform_async_setup_entry, ) +PARALLEL_UPDATES = 0 + class EsphomeValve(EsphomeEntity[ValveInfo, ValveState], ValveEntity): """A valve implementation for ESPHome.""" @@ -63,7 +65,7 @@ class EsphomeValve(EsphomeEntity[ValveInfo, ValveState], ValveEntity): @property @esphome_state_property - def current_valve_position(self) -> int | None: + def current_valve_position(self) -> int: """Return current position of valve. 0 is closed, 100 is open.""" return round(self._state.position * 100.0) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index b44dc9791b0..40439c1eb02 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -152,7 +152,7 @@ class EvoZone(EvoChild, EvoClimateEntity): super().__init__(coordinator, evo_device) self._evo_id = evo_device.id - if evo_device.model.startswith("VisionProWifi"): + if evo_device.id == evo_device.tcs.id: # this system does not have a distinct ID for the zone self._attr_unique_id = f"{evo_device.id}z" else: diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index e6de538335c..1d165c7bbe8 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -32,4 +32,4 @@ EU_URL = "apiieu.ezvizlife.com" RUSSIA_URL = "apirus.ezvizru.com" DEFAULT_CAMERA_USERNAME = "admin" DEFAULT_TIMEOUT = 25 -DEFAULT_FFMPEG_ARGUMENTS = "" +DEFAULT_FFMPEG_ARGUMENTS = "/Streaming/Channels/102" diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 31617cb220b..57c58d3a2b1 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -45,7 +45,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) # if this is the last entry, remove the storage if len(entries) == 1: hass.data.pop(MY_KEY) - return await hass.config_entries.async_unload_platforms(entry, Platform.EVENT) + return await hass.config_entries.async_unload_platforms(entry, [Platform.EVENT]) async def _async_update_listener( diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index a4f59d8ab76..a74656eef11 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -12,11 +12,12 @@ from pyfibaro.fibaro_client import ( FibaroClient, FibaroConnectFailed, ) -from pyfibaro.fibaro_data_helper import read_rooms +from pyfibaro.fibaro_data_helper import find_master_devices, read_rooms from pyfibaro.fibaro_device import DeviceModel +from pyfibaro.fibaro_device_manager import FibaroDeviceManager from pyfibaro.fibaro_info import InfoModel from pyfibaro.fibaro_scene import SceneModel -from pyfibaro.fibaro_state_resolver import FibaroEvent, FibaroStateResolver +from pyfibaro.fibaro_state_resolver import FibaroEvent from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform @@ -81,8 +82,8 @@ class FibaroController: self._client = fibaro_client self._fibaro_info = info - # Whether to import devices from plugins - self._import_plugins = import_plugins + # The fibaro device manager exposes higher level API to access fibaro devices + self._fibaro_device_manager = FibaroDeviceManager(fibaro_client, import_plugins) # Mapping roomId to room object self._room_map = read_rooms(fibaro_client) self._device_map: dict[int, DeviceModel] # Mapping deviceId to device object @@ -91,79 +92,30 @@ class FibaroController: ) # List of devices by entity platform # All scenes self._scenes = self._client.read_scenes() - self._callbacks: dict[int, list[Any]] = {} # Update value callbacks by deviceId - # Event callbacks by device id - self._event_callbacks: dict[int, list[Callable[[FibaroEvent], None]]] = {} # Unique serial number of the hub self.hub_serial = info.serial_number # Device infos by fibaro device id self._device_infos: dict[int, DeviceInfo] = {} self._read_devices() - def enable_state_handler(self) -> None: - """Start StateHandler thread for monitoring updates.""" - self._client.register_update_handler(self._on_state_change) + def disconnect(self) -> None: + """Close push channel.""" + self._fibaro_device_manager.close() - def disable_state_handler(self) -> None: - """Stop StateHandler thread used for monitoring updates.""" - self._client.unregister_update_handler() - - def _on_state_change(self, state: Any) -> None: - """Handle change report received from the HomeCenter.""" - callback_set = set() - for change in state.get("changes", []): - try: - dev_id = change.pop("id") - if dev_id not in self._device_map: - continue - device = self._device_map[dev_id] - for property_name, value in change.items(): - if property_name == "log": - if value and value != "transfer OK": - _LOGGER.debug("LOG %s: %s", device.friendly_name, value) - continue - if property_name == "logTemp": - continue - if property_name in device.properties: - device.properties[property_name] = value - _LOGGER.debug( - "<- %s.%s = %s", device.ha_id, property_name, str(value) - ) - else: - _LOGGER.warning("%s.%s not found", device.ha_id, property_name) - if dev_id in self._callbacks: - callback_set.add(dev_id) - except (ValueError, KeyError): - pass - for item in callback_set: - for callback in self._callbacks[item]: - callback() - - resolver = FibaroStateResolver(state) - for event in resolver.get_events(): - # event does not always have a fibaro id, therefore it is - # essential that we first check for relevant event type - if ( - event.event_type.lower() == "centralsceneevent" - and event.fibaro_id in self._event_callbacks - ): - for callback in self._event_callbacks[event.fibaro_id]: - callback(event) - - def register(self, device_id: int, callback: Any) -> None: + def register( + self, device_id: int, callback: Callable[[DeviceModel], None] + ) -> Callable[[], None]: """Register device with a callback for updates.""" - device_callbacks = self._callbacks.setdefault(device_id, []) - device_callbacks.append(callback) + return self._fibaro_device_manager.add_change_listener(device_id, callback) def register_event( self, device_id: int, callback: Callable[[FibaroEvent], None] - ) -> None: + ) -> Callable[[], None]: """Register device with a callback for central scene events. The callback receives one parameter with the event. """ - device_callbacks = self._event_callbacks.setdefault(device_id, []) - device_callbacks.append(callback) + return self._fibaro_device_manager.add_event_listener(device_id, callback) def get_children(self, device_id: int) -> list[DeviceModel]: """Get a list of child devices.""" @@ -224,35 +176,18 @@ class FibaroController: platform = Platform.LIGHT return platform - def _create_device_info( - self, device: DeviceModel, devices: list[DeviceModel] - ) -> None: - """Create the device info. Unrooted entities are directly shown below the home center.""" + def _create_device_info(self, main_device: DeviceModel) -> None: + """Create the device info for a main device.""" - # The home center is always id 1 (z-wave primary controller) - if device.parent_fibaro_id <= 1: - return - - master_entity: DeviceModel | None = None - if device.parent_fibaro_id == 1: - master_entity = device - else: - for parent in devices: - if parent.fibaro_id == device.parent_fibaro_id: - master_entity = parent - if master_entity is None: - _LOGGER.error("Parent with id %s not found", device.parent_fibaro_id) - return - - if "zwaveCompany" in master_entity.properties: - manufacturer = master_entity.properties.get("zwaveCompany") + if "zwaveCompany" in main_device.properties: + manufacturer = main_device.properties.get("zwaveCompany") else: manufacturer = None - self._device_infos[master_entity.fibaro_id] = DeviceInfo( - identifiers={(DOMAIN, master_entity.fibaro_id)}, + self._device_infos[main_device.fibaro_id] = DeviceInfo( + identifiers={(DOMAIN, main_device.fibaro_id)}, manufacturer=manufacturer, - name=master_entity.name, + name=main_device.name, via_device=(DOMAIN, self.hub_serial), ) @@ -276,6 +211,10 @@ class FibaroController: """Return list of scenes.""" return self._scenes + def get_all_devices(self) -> list[DeviceModel]: + """Return list of all fibaro devices.""" + return self._fibaro_device_manager.get_devices() + def read_fibaro_info(self) -> InfoModel: """Return the general info about the hub.""" return self._fibaro_info @@ -286,7 +225,11 @@ class FibaroController: def _read_devices(self) -> None: """Read and process the device list.""" - devices = self._client.read_devices() + devices = self._fibaro_device_manager.get_devices() + + for main_device in find_master_devices(devices): + self._create_device_info(main_device) + self._device_map = {} last_climate_parent = None last_endpoint = None @@ -301,13 +244,11 @@ class FibaroController: device.ha_id = ( f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}" ) - platform = None - if device.enabled and (not device.is_plugin or self._import_plugins): - platform = self._map_device_to_platform(device) + + platform = self._map_device_to_platform(device) if platform is None: continue device.unique_id_str = f"{slugify(self.hub_serial)}.{device.fibaro_id}" - self._create_device_info(device, devices) self._device_map[device.fibaro_id] = device _LOGGER.debug( "%s (%s, %s) -> %s %s", @@ -393,8 +334,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bo await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - controller.enable_state_handler() - return True @@ -403,8 +342,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> b _LOGGER.debug("Shutting down Fibaro connection") unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - entry.runtime_data.disable_state_handler() - + entry.runtime_data.disconnect() return unload_ok diff --git a/homeassistant/components/fibaro/diagnostics.py b/homeassistant/components/fibaro/diagnostics.py new file mode 100644 index 00000000000..2f1f397a69a --- /dev/null +++ b/homeassistant/components/fibaro/diagnostics.py @@ -0,0 +1,56 @@ +"""Diagnostics support for fibaro integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from pyfibaro.fibaro_device import DeviceModel + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from . import CONF_IMPORT_PLUGINS, FibaroConfigEntry + +TO_REDACT = {"password"} + + +def _create_diagnostics_data( + config_entry: FibaroConfigEntry, devices: list[DeviceModel] +) -> dict[str, Any]: + """Combine diagnostics information and redact sensitive information.""" + return { + "config": {CONF_IMPORT_PLUGINS: config_entry.data.get(CONF_IMPORT_PLUGINS)}, + "fibaro_devices": async_redact_data([d.raw_data for d in devices], TO_REDACT), + } + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: FibaroConfigEntry +) -> Mapping[str, Any]: + """Return diagnostics for a config entry.""" + controller = config_entry.runtime_data + devices = controller.get_all_devices() + return _create_diagnostics_data(config_entry, devices) + + +async def async_get_device_diagnostics( + hass: HomeAssistant, config_entry: FibaroConfigEntry, device: DeviceEntry +) -> Mapping[str, Any]: + """Return diagnostics for a device.""" + controller = config_entry.runtime_data + devices = controller.get_all_devices() + + ha_device_id = next(iter(device.identifiers))[1] + if ha_device_id == controller.hub_serial: + # special case where the device is representing the fibaro hub + return _create_diagnostics_data(config_entry, devices) + + # normal devices are represented by a parent / child structure + filtered_devices = [ + device + for device in devices + if ha_device_id in (device.fibaro_id, device.parent_fibaro_id) + ] + return _create_diagnostics_data(config_entry, filtered_devices) diff --git a/homeassistant/components/fibaro/entity.py b/homeassistant/components/fibaro/entity.py index 5375b058315..e8ed5afc500 100644 --- a/homeassistant/components/fibaro/entity.py +++ b/homeassistant/components/fibaro/entity.py @@ -36,9 +36,13 @@ class FibaroEntity(Entity): async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" - self.controller.register(self.fibaro_device.fibaro_id, self._update_callback) + self.async_on_remove( + self.controller.register( + self.fibaro_device.fibaro_id, self._update_callback + ) + ) - def _update_callback(self) -> None: + def _update_callback(self, fibaro_device: DeviceModel) -> None: """Update the state.""" self.schedule_update_ha_state(True) diff --git a/homeassistant/components/fibaro/event.py b/homeassistant/components/fibaro/event.py index 0beea2e336e..ad44719c8be 100644 --- a/homeassistant/components/fibaro/event.py +++ b/homeassistant/components/fibaro/event.py @@ -60,11 +60,16 @@ class FibaroEventEntity(FibaroEntity, EventEntity): await super().async_added_to_hass() # Register event callback - self.controller.register_event( - self.fibaro_device.fibaro_id, self._event_callback + self.async_on_remove( + self.controller.register_event( + self.fibaro_device.fibaro_id, self._event_callback + ) ) def _event_callback(self, event: FibaroEvent) -> None: - if event.key_id == self._button: + if ( + event.event_type.lower() == "centralsceneevent" + and event.key_id == self._button + ): self._trigger_event(event.key_event_type) self.schedule_update_ha_state() diff --git a/homeassistant/components/fireservicerota/strings.json b/homeassistant/components/fireservicerota/strings.json index 7b4bd583b63..9a23161b7ec 100644 --- a/homeassistant/components/fireservicerota/strings.json +++ b/homeassistant/components/fireservicerota/strings.json @@ -9,7 +9,7 @@ } }, "reauth_confirm": { - "description": "Authentication tokens became invalid, login to recreate them.", + "description": "Authentication tokens became invalid, log in to recreate them.", "data": { "password": "[%key:common::config_flow::data::password%]" } diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 81e61f2554a..4aea43f0bec 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -1,6 +1,5 @@ """The Flipr integration.""" -from collections import Counter import logging from flipr_api import FliprAPIRestClient @@ -8,10 +7,7 @@ from flipr_api import FliprAPIRestClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError -from homeassistant.helpers import issue_registry as ir -from .const import DOMAIN from .coordinator import ( FliprConfigEntry, FliprData, @@ -27,9 +23,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: FliprConfigEntry) -> bool: """Set up flipr from a config entry.""" - # Detect invalid old config entry and raise error if found - detect_invalid_old_configuration(hass, entry) - config = entry.data username = config[CONF_EMAIL] @@ -64,47 +57,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -def detect_invalid_old_configuration(hass: HomeAssistant, entry: ConfigEntry): - """Detect invalid old configuration and raise error if found.""" - - def find_duplicate_entries(entries): - values = [e.data["email"] for e in entries] - _LOGGER.debug("Detecting duplicates in values : %s", values) - return any(count > 1 for count in Counter(values).values()) - - entries = hass.config_entries.async_entries(DOMAIN) - - if find_duplicate_entries(entries): - ir.async_create_issue( - hass, - DOMAIN, - "duplicate_config", - breaks_in_ha_version="2025.4.0", - is_fixable=False, - severity=ir.IssueSeverity.ERROR, - translation_key="duplicate_config", - ) - - raise ConfigEntryError( - "Duplicate entries found for flipr with the same user email. Please remove one of it manually. Multiple fliprs will be automatically detected after restart." - ) - - -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Migrate config entry.""" - _LOGGER.debug("Migration of flipr config from version %s", entry.version) - - if entry.version == 1: - # In version 1, we have flipr device as config entry unique id - # and one device per config entry. - # We need to migrate to a new config entry that may contain multiple devices. - # So we change the entry data to match config_flow evolution. - login = entry.data[CONF_EMAIL] - - hass.config_entries.async_update_entry(entry, version=2, unique_id=login) - - _LOGGER.debug("Migration of flipr config to version 2 successful") - - return True diff --git a/homeassistant/components/flipr/strings.json b/homeassistant/components/flipr/strings.json index 631b0ce5488..5c1a55e8b2a 100644 --- a/homeassistant/components/flipr/strings.json +++ b/homeassistant/components/flipr/strings.json @@ -14,7 +14,7 @@ "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%]", - "no_flipr_id_found": "No flipr or hub associated to your account for now. You should verify it is working with the Flipr's mobile app first." + "no_flipr_id_found": "No Flipr or hub associated to your account for now. You should verify it is working with the Flipr mobile app first." } }, "entity": { @@ -44,17 +44,11 @@ "hub_mode": { "name": "Mode", "state": { - "auto": "Automatic", - "manual": "Manual", + "auto": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]", "planning": "Planning" } } } - }, - "issues": { - "duplicate_config": { - "title": "Multiple flipr configurations with the same account", - "description": "The Flipr integration has been updated to work account based rather than device based. This means that if you have 2 devices, you only need one configuration. For every account you have, please delete all but one configuration and restart Home Assistant for it to set up the devices linked to your account." - } } } diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index efa96eca5a7..753bdff8cec 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -72,7 +72,11 @@ async def get_hosts_list_if_supported( supports_hosts: bool = True fbx_devices: list[dict[str, Any]] = [] try: - fbx_devices = await fbx_api.lan.get_hosts_list() or [] + fbx_interfaces = await fbx_api.lan.get_interfaces() or [] + for interface in fbx_interfaces: + fbx_devices.extend( + await fbx_api.lan.get_hosts_list(interface["name"]) or [] + ) except HttpRequestError as err: if ( (matcher := re.search(r"Request failed \(APIResponse: (.+)\)", str(err))) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 6bc8bb571d4..2a4eb8c82b5 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -20,6 +20,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class FritzBinarySensorEntityDescription( diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index ddc0be3a6d9..926e233d159 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -31,6 +31,9 @@ from .entity import FritzDeviceBase _LOGGER = logging.getLogger(__name__) +# Set a sane value to avoid too many updates +PARALLEL_UPDATES = 5 + @dataclass(frozen=True, kw_only=True) class FritzButtonDescription(ButtonEntityDescription): diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index c0121ed9aa1..9199692f564 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -2,13 +2,12 @@ from __future__ import annotations -from collections.abc import Callable, ValuesView +from collections.abc import Callable, Mapping, ValuesView from dataclasses import dataclass, field from datetime import datetime, timedelta from functools import partial import logging import re -from types import MappingProxyType from typing import Any, TypedDict, cast from fritzconnection import FritzConnection @@ -187,7 +186,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): ) self._devices: dict[str, FritzDevice] = {} - self._options: MappingProxyType[str, Any] | None = None + self._options: Mapping[str, Any] | None = None self._unique_id: str | None = None self.connection: FritzConnection = None self.fritz_guest_wifi: FritzGuestWLAN = None @@ -213,9 +212,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): str, Callable[[FritzStatus, StateType], Any] ] = {} - async def async_setup( - self, options: MappingProxyType[str, Any] | None = None - ) -> None: + async def async_setup(self, options: Mapping[str, Any] | None = None) -> None: """Wrap up FritzboxTools class setup.""" self._options = options await self.hass.async_add_executor_job(self.setup) diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index e066219342e..618214a1c55 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -22,6 +22,9 @@ from .entity import FritzDeviceBase _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py index d329ec318c5..1fc70dedc6c 100644 --- a/homeassistant/components/fritz/image.py +++ b/homeassistant/components/fritz/image.py @@ -18,6 +18,9 @@ from .entity import FritzBoxBaseEntity _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index 361d61df6a0..c2d18a0be84 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -4,17 +4,13 @@ rules: appropriate-polling: done brands: done common-modules: done - config-flow-test-coverage: - status: todo - comment: one coverage miss in line 110 + 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: - status: todo - comment: include the proper docs snippet + docs-removal-instructions: done entity-event-setup: done entity-unique-id: done has-entity-name: @@ -29,15 +25,11 @@ rules: action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done - docs-installation-parameters: - status: todo - comment: add the proper configuration_basic block + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: - status: todo - comment: not set at the moment, we use a coordinator + parallel-updates: done reauthentication-flow: done test-coverage: status: todo @@ -48,7 +40,7 @@ rules: diagnostics: done discovery-update-info: todo discovery: done - docs-data-update: todo + docs-data-update: done docs-examples: done docs-known-limitations: status: exempt diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 243b3b5eb4c..65a776b9ad5 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -32,6 +32,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + def _uptime_calculation(seconds_uptime: float, last_value: datetime | None) -> datetime: """Calculate uptime with deviation.""" diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index b627e0150a9..a033e45fcec 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -38,6 +38,9 @@ from .entity import FritzBoxBaseEntity, FritzDeviceBase _LOGGER = logging.getLogger(__name__) +# Set a sane value to avoid too many updates +PARALLEL_UPDATES = 5 + async def _async_deflection_entities_list( avm_wrapper: AvmWrapper, device_friendly_name: str diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py index 5d064dc3035..4e54f4c28d3 100644 --- a/homeassistant/components/fritz/update.py +++ b/homeassistant/components/fritz/update.py @@ -20,6 +20,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) +# Set a sane value to avoid too many updates +PARALLEL_UPDATES = 5 + @dataclass(frozen=True, kw_only=True) class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescription): diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 75683017cb7..791039add31 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -22,19 +22,14 @@ from .entity import FritzBoxDeviceEntity from .model import FritzEntityDescriptionMixinBase -@dataclass(frozen=True) -class FritzEntityDescriptionMixinBinarySensor(FritzEntityDescriptionMixinBase): - """BinarySensor description mixin for Fritz!Smarthome entities.""" - - is_on: Callable[[FritzhomeDevice], bool | None] - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class FritzBinarySensorEntityDescription( - BinarySensorEntityDescription, FritzEntityDescriptionMixinBinarySensor + BinarySensorEntityDescription, FritzEntityDescriptionMixinBase ): """Description for Fritz!Smarthome binary sensor entities.""" + is_on: Callable[[FritzhomeDevice], bool | None] + BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( FritzBinarySensorEntityDescription( @@ -60,6 +55,32 @@ BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( suitable=lambda device: device.device_lock is not None, is_on=lambda device: not device.device_lock, ), + FritzBinarySensorEntityDescription( + key="battery_low", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + suitable=lambda device: device.battery_low is not None, + is_on=lambda device: device.battery_low, + entity_registry_enabled_default=False, + ), + FritzBinarySensorEntityDescription( + key="holiday_active", + translation_key="holiday_active", + suitable=lambda device: device.holiday_active is not None, + is_on=lambda device: device.holiday_active, + ), + FritzBinarySensorEntityDescription( + key="summer_active", + translation_key="summer_active", + suitable=lambda device: device.summer_active is not None, + is_on=lambda device: device.summer_active, + ), + FritzBinarySensorEntityDescription( + key="window_open", + translation_key="window_open", + suitable=lambda device: device.window_open is not None, + is_on=lambda device: device.window_open, + ), ) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 57c7e2a696f..573877fa71b 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -53,8 +53,11 @@ MAX_TEMPERATURE = 28 # special temperatures for on/off in Fritz!Box API (modified by pyfritzhome) ON_API_TEMPERATURE = 127.0 OFF_API_TEMPERATURE = 126.5 -ON_REPORT_SET_TEMPERATURE = 30.0 -OFF_REPORT_SET_TEMPERATURE = 0.0 +PRESET_API_HKR_STATE_MAPPING = { + PRESET_COMFORT: "comfort", + PRESET_BOOST: "on", + PRESET_ECO: "eco", +} async def async_setup_entry( @@ -128,29 +131,29 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): return self.data.actual_temperature # type: ignore [no-any-return] @property - def target_temperature(self) -> float: + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - if self.data.target_temperature == ON_API_TEMPERATURE: - return ON_REPORT_SET_TEMPERATURE - if self.data.target_temperature == OFF_API_TEMPERATURE: - return OFF_REPORT_SET_TEMPERATURE + if self.data.target_temperature in [ON_API_TEMPERATURE, OFF_API_TEMPERATURE]: + return None return self.data.target_temperature # type: ignore [no-any-return] + async def async_set_hkr_state(self, hkr_state: str) -> None: + """Set the state of the climate.""" + await self.hass.async_add_executor_job(self.data.set_hkr_state, hkr_state, True) + await self.coordinator.async_refresh() + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is HVACMode.OFF: - await self.async_set_hvac_mode(hvac_mode) + self.check_active_or_lock_mode() + if kwargs.get(ATTR_HVAC_MODE) is HVACMode.OFF: + await self.async_set_hkr_state("off") elif (target_temp := kwargs.get(ATTR_TEMPERATURE)) is not None: - if target_temp == OFF_API_TEMPERATURE: - target_temp = OFF_REPORT_SET_TEMPERATURE - elif target_temp == ON_API_TEMPERATURE: - target_temp = ON_REPORT_SET_TEMPERATURE await self.hass.async_add_executor_job( self.data.set_target_temperature, target_temp, True ) + await self.coordinator.async_refresh() else: return - await self.coordinator.async_refresh() @property def hvac_mode(self) -> HVACMode: @@ -159,28 +162,21 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): return HVACMode.HEAT if self.data.summer_active: return HVACMode.OFF - if self.data.target_temperature in ( - OFF_REPORT_SET_TEMPERATURE, - OFF_API_TEMPERATURE, - ): + if self.data.target_temperature == OFF_API_TEMPERATURE: return HVACMode.OFF return HVACMode.HEAT async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new operation mode.""" - if self.data.holiday_active or self.data.summer_active: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="change_hvac_while_active_mode", - ) + self.check_active_or_lock_mode() if self.hvac_mode is hvac_mode: LOGGER.debug( "%s is already in requested hvac mode %s", self.name, hvac_mode ) return if hvac_mode is HVACMode.OFF: - await self.async_set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE) + await self.async_set_hkr_state("off") else: if value_scheduled_preset(self.data) == PRESET_ECO: target_temp = self.data.eco_temperature @@ -205,21 +201,13 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" - if self.data.holiday_active or self.data.summer_active: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="change_preset_while_active_mode", - ) - if preset_mode == PRESET_COMFORT: - await self.async_set_temperature(temperature=self.data.comfort_temperature) - elif preset_mode == PRESET_ECO: - await self.async_set_temperature(temperature=self.data.eco_temperature) - elif preset_mode == PRESET_BOOST: - await self.async_set_temperature(temperature=ON_REPORT_SET_TEMPERATURE) + self.check_active_or_lock_mode() + await self.async_set_hkr_state(PRESET_API_HKR_STATE_MAPPING[preset_mode]) @property def extra_state_attributes(self) -> ClimateExtraAttributes: """Return the device specific state attributes.""" + # deprecated with #143394, can be removed in 2025.11 attrs: ClimateExtraAttributes = { ATTR_STATE_BATTERY_LOW: self.data.battery_low, } @@ -235,3 +223,17 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): attrs[ATTR_STATE_WINDOW_OPEN] = self.data.window_open return attrs + + def check_active_or_lock_mode(self) -> None: + """Check if in summer/vacation mode or lock enabled.""" + if self.data.holiday_active or self.data.summer_active: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="change_settings_while_active_mode", + ) + + if self.data.lock: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="change_settings_while_lock_enabled", + ) diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 34df3885deb..adc63dd2c2e 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -77,12 +77,11 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat self.configuration_url = self.fritz.get_prefixed_host() await self.async_config_entry_first_refresh() - self.cleanup_removed_devices( - list(self.data.devices) + list(self.data.templates) - ) + self.cleanup_removed_devices(self.data) - def cleanup_removed_devices(self, available_ains: list[str]) -> None: + def cleanup_removed_devices(self, data: FritzboxCoordinatorData) -> None: """Cleanup entity and device registry from removed devices.""" + available_ains = list(data.devices) + list(data.templates) entity_reg = er.async_get(self.hass) for entity in er.async_entries_for_config_entry( entity_reg, self.config_entry.entry_id @@ -91,8 +90,13 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat LOGGER.debug("Removing obsolete entity entry %s", entity.entity_id) entity_reg.async_remove(entity.entity_id) + available_main_ains = [ + ain + for ain, dev in data.devices.items() + if dev.device_and_unit_id[1] is None + ] device_reg = dr.async_get(self.hass) - identifiers = {(DOMAIN, ain) for ain in available_ains} + identifiers = {(DOMAIN, ain) for ain in available_main_ains} for device in dr.async_entries_for_config_entry( device_reg, self.config_entry.entry_id ): @@ -165,12 +169,26 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat """Fetch all device data.""" new_data = await self.hass.async_add_executor_job(self._update_fritz_devices) + for device in new_data.devices.values(): + # create device registry entry for new main devices + if ( + device.ain not in self.data.devices + and device.device_and_unit_id[1] is None + ): + dr.async_get(self.hass).async_get_or_create( + config_entry_id=self.config_entry.entry_id, + name=device.name, + identifiers={(DOMAIN, device.ain)}, + manufacturer=device.manufacturer, + model=device.productname, + sw_version=device.fw_version, + configuration_url=self.configuration_url, + ) + if ( self.data.devices.keys() - new_data.devices.keys() or self.data.templates.keys() - new_data.templates.keys() ): - self.cleanup_removed_devices( - list(new_data.devices) + list(new_data.templates) - ) + self.cleanup_removed_devices(new_data) return new_data diff --git a/homeassistant/components/fritzbox/entity.py b/homeassistant/components/fritzbox/entity.py index cd619588bc1..bbc7d9fe276 100644 --- a/homeassistant/components/fritzbox/entity.py +++ b/homeassistant/components/fritzbox/entity.py @@ -58,11 +58,4 @@ class FritzBoxDeviceEntity(FritzBoxEntity): @property def device_info(self) -> DeviceInfo: """Return device specific attributes.""" - return DeviceInfo( - name=self.data.name, - identifiers={(DOMAIN, self.ain)}, - manufacturer=self.data.manufacturer, - model=self.data.productname, - sw_version=self.data.fw_version, - configuration_url=self.coordinator.configuration_url, - ) + return DeviceInfo(identifiers={(DOMAIN, self.data.device_and_unit_id[0])}) diff --git a/homeassistant/components/fritzbox/icons.json b/homeassistant/components/fritzbox/icons.json index 5eb819cdde8..4557b23511c 100644 --- a/homeassistant/components/fritzbox/icons.json +++ b/homeassistant/components/fritzbox/icons.json @@ -1,5 +1,28 @@ { "entity": { + "binary_sensor": { + "holiday_active": { + "default": "mdi:bag-suitcase-outline", + "state": { + "on": "mdi:bag-suitcase-outline", + "off": "mdi:bag-suitcase-off-outline" + } + }, + "summer_active": { + "default": "mdi:radiator-off", + "state": { + "on": "mdi:radiator-off", + "off": "mdi:radiator" + } + }, + "window_open": { + "default": "mdi:window-open", + "state": { + "on": "mdi:window-open", + "off": "mdi:window-closed" + } + } + }, "climate": { "thermostat": { "state_attributes": { diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 801a3a67a6e..8e3ab5d6892 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -35,20 +35,14 @@ from .entity import FritzBoxDeviceEntity from .model import FritzEntityDescriptionMixinBase -@dataclass(frozen=True) -class FritzEntityDescriptionMixinSensor(FritzEntityDescriptionMixinBase): - """Sensor description mixin for Fritz!Smarthome entities.""" - - native_value: Callable[[FritzhomeDevice], StateType | datetime] - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class FritzSensorEntityDescription( - SensorEntityDescription, FritzEntityDescriptionMixinSensor + SensorEntityDescription, FritzEntityDescriptionMixinBase ): """Description for Fritz!Smarthome sensor entities.""" entity_category_fn: Callable[[FritzhomeDevice], EntityCategory | None] | None = None + native_value: Callable[[FritzhomeDevice], StateType | datetime] def suitable_eco_temperature(device: FritzhomeDevice) -> bool: diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index e0df30875bc..38bc6dc9c39 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -55,7 +55,10 @@ "binary_sensor": { "alarm": { "name": "Alarm" }, "device_lock": { "name": "Button lock via UI" }, - "lock": { "name": "Button lock on device" } + "holiday_active": { "name": "Holiday mode" }, + "lock": { "name": "Button lock on device" }, + "summer_active": { "name": "Summer mode" }, + "window_open": { "name": "Open window detected" } }, "climate": { "thermostat": { @@ -85,11 +88,11 @@ "manual_switching_disabled": { "message": "Can't toggle switch while manual switching is disabled for the device." }, - "change_preset_while_active_mode": { - "message": "Can't change preset while holiday or summer mode is active on the device." + "change_settings_while_lock_enabled": { + "message": "Can't change settings while manual access for telephone, app, or user interface is disabled on the device" }, - "change_hvac_while_active_mode": { - "message": "Can't change HVAC mode while holiday or summer mode is active on the device." + "change_settings_while_active_mode": { + "message": "Can't change settings while holiday or summer mode is active on the device." } } } diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index b77f6fec83c..7c42cca29de 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -82,13 +82,13 @@ "ac_frequency_too_high": "AC frequency too high", "ac_frequency_too_low": "AC frequency too low", "ac_grid_outside_permissible_limits": "AC grid outside the permissible limits", - "stand_alone_operation_detected": "Stand alone operation detected", + "stand_alone_operation_detected": "Stand-alone operation detected", "rcmu_error": "RCMU error", "arc_detection_triggered": "Arc detection triggered", "overcurrent_ac": "Overcurrent (AC)", "overcurrent_dc": "Overcurrent (DC)", - "dc_module_over_temperature": "DC module over temperature", - "ac_module_over_temperature": "AC module over temperature", + "dc_module_over_temperature": "DC module overtemperature", + "ac_module_over_temperature": "AC module overtemperature", "no_power_fed_in_despite_closed_relay": "No power being fed in, despite closed relay", "pv_output_too_low_for_feeding_energy_into_the_grid": "PV output too low for feeding energy into the grid", "low_pv_voltage_dc_input_voltage_too_low": "Low PV voltage - DC input voltage too low for feeding energy into the grid", @@ -133,16 +133,16 @@ "no_energy_fed_by_mppt1_past_24_hours": "No energy fed into the grid by MPPT1 in the past 24 hours", "dc_low_string_1": "DC low string 1", "dc_low_string_2": "DC low string 2", - "derating_caused_by_over_frequency": "Derating caused by over-frequency", + "derating_caused_by_over_frequency": "Derating caused by overfrequency", "arc_detector_switched_off": "Arc detector switched off (e.g. during external arc monitoring)", - "grid_voltage_dependent_power_reduction_active": "Grid Voltage Dependent Power Reduction is active", + "grid_voltage_dependent_power_reduction_active": "Grid voltage-dependent power reduction (GVDPR) is active", "can_bus_full": "CAN bus is full", "ac_module_temperature_sensor_faulty_l3": "AC module temperature sensor faulty (L3)", "dc_module_temperature_sensor_faulty": "DC module temperature sensor faulty", "internal_processor_status": "Warning about the internal processor status. See status code for more information", "eeprom_reinitialised": "EEPROM has been re-initialised", "initialisation_error_usb_flash_drive_not_supported": "Initialisation error – USB flash drive is not supported", - "initialisation_error_usb_stick_over_current": "Initialisation error – Over current on USB stick", + "initialisation_error_usb_stick_over_current": "Initialisation error – Overcurrent on USB stick", "no_usb_flash_drive_connected": "No USB flash drive connected", "update_file_not_recognised_or_missing": "Update file not recognised or not present", "update_file_does_not_match_device": "Update file does not match the device, update file too old", @@ -182,10 +182,10 @@ "state": { "startup": "Startup", "running": "Running", - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "bootloading": "Bootloading", - "error": "Error", - "idle": "Idle", + "error": "[%key:common::state::error%]", + "idle": "[%key:common::state::idle%]", "ready": "Ready", "sleeping": "Sleeping" } @@ -317,11 +317,11 @@ "state_message": { "name": "State message", "state": { + "fault": "[%key:common::state::fault%]", + "critical_fault": "Critical fault", "up_and_running": "Up and running", "keep_minimum_temperature": "Keep minimum temperature", "legionella_protection": "Legionella protection", - "critical_fault": "Critical fault", - "fault": "Fault", "boost_mode": "Boost mode" } }, @@ -362,7 +362,7 @@ "name": "Relative autonomy" }, "relative_self_consumption": { - "name": "Relative self consumption" + "name": "Relative self-consumption" }, "capacity_maximum": { "name": "Maximum capacity" diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 64b49588ba1..84062384bf5 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250411.0"] + "requirements": ["home-assistant-frontend==20250507.0"] } diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index 5841456c034..fdfdf7910ae 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -2,7 +2,7 @@ "common": { "data_description_password": "The Remote Admin password from the Fully Kiosk Browser app settings.", "data_description_ssl": "Is the Fully Kiosk app configured to require SSL for the connection?", - "data_description_verify_ssl": "Should SSL certificartes be verified? This should be off for self-signed certificates." + "data_description_verify_ssl": "Should SSL certificates be verified? This should be off for self-signed certificates." }, "config": { "step": { diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index 1a25f654e19..a10fa5bfc47 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -9,8 +9,8 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "username": "The email address to login to your FYTA account.", - "password": "The password to login to your FYTA account." + "username": "The email address to log in to your FYTA account.", + "password": "The password to log in to your FYTA account." } }, "reauth_confirm": { @@ -79,9 +79,9 @@ "state": { "no_data": "No data", "too_low": "Too low", - "low": "Low", + "low": "[%key:common::state::low%]", "perfect": "Perfect", - "high": "High", + "high": "[%key:common::state::high%]", "too_high": "Too high" } }, @@ -90,9 +90,9 @@ "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%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, @@ -101,9 +101,9 @@ "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%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, @@ -112,9 +112,9 @@ "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%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, @@ -123,9 +123,9 @@ "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%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, diff --git a/homeassistant/components/gaggenau/__init__.py b/homeassistant/components/gaggenau/__init__.py new file mode 100644 index 00000000000..2c03410c35d --- /dev/null +++ b/homeassistant/components/gaggenau/__init__.py @@ -0,0 +1 @@ +"""Gaggenau virtual integration.""" diff --git a/homeassistant/components/gaggenau/manifest.json b/homeassistant/components/gaggenau/manifest.json new file mode 100644 index 00000000000..9dc38b2e4b3 --- /dev/null +++ b/homeassistant/components/gaggenau/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "gaggenau", + "name": "Gaggenau", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/garages_amsterdam/manifest.json b/homeassistant/components/garages_amsterdam/manifest.json index 4d4bb9f6fb5..7652b4b6f3b 100644 --- a/homeassistant/components/garages_amsterdam/manifest.json +++ b/homeassistant/components/garages_amsterdam/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/garages_amsterdam", "iot_class": "cloud_polling", - "requirements": ["odp-amsterdam==6.0.2"] + "requirements": ["odp-amsterdam==6.1.1"] } diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 35c5ae93b72..b5e25c08851 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/generic", "integration_type": "device", "iot_class": "local_push", - "requirements": ["av==13.1.0", "Pillow==11.1.0"] + "requirements": ["av==13.1.0", "Pillow==11.2.1"] } diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 190caa58b3f..185040f02c9 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -539,10 +539,14 @@ class GenericThermostat(ClimateEntity, RestoreEntity): return assert self._cur_temp is not None and self._target_temp is not None - too_cold = self._target_temp >= self._cur_temp + self._cold_tolerance - too_hot = self._cur_temp >= self._target_temp + self._hot_tolerance + + min_temp = self._target_temp - self._cold_tolerance + max_temp = self._target_temp + self._hot_tolerance + if self._is_device_active: - if (self.ac_mode and too_cold) or (not self.ac_mode and too_hot): + if (self.ac_mode and self._cur_temp <= min_temp) or ( + not self.ac_mode and self._cur_temp >= max_temp + ): _LOGGER.debug("Turning off heater %s", self.heater_entity_id) await self._async_heater_turn_off() elif time is not None: @@ -552,7 +556,9 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self.heater_entity_id, ) await self._async_heater_turn_on() - elif (self.ac_mode and too_hot) or (not self.ac_mode and too_cold): + elif (self.ac_mode and self._cur_temp > max_temp) or ( + not self.ac_mode and self._cur_temp < min_temp + ): _LOGGER.debug("Turning on heater %s", self.heater_entity_id) await self._async_heater_turn_on() elif time is not None: diff --git a/homeassistant/components/generic_thermostat/strings.json b/homeassistant/components/generic_thermostat/strings.json index 58280e99543..735e0b0f9e6 100644 --- a/homeassistant/components/generic_thermostat/strings.json +++ b/homeassistant/components/generic_thermostat/strings.json @@ -21,17 +21,17 @@ "heater": "Switch entity used to cool or heat depending on A/C mode.", "target_sensor": "Temperature sensor that reflects the current temperature.", "min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.", - "cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor equals or goes below 24.5.", + "cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor goes below 24.5.", "hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5." } }, "presets": { "title": "Temperature presets", "data": { - "away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "home_temp": "[%key:common::state::home%]", + "away_temp": "[%key:common::state::not_home%]", "comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", "eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", - "home_temp": "[%key:common::state::home%]", "sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]", "activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]" } @@ -63,10 +63,10 @@ "presets": { "title": "[%key:component::generic_thermostat::config::step::presets::title%]", "data": { - "away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "home_temp": "[%key:common::state::home%]", + "away_temp": "[%key:common::state::not_home%]", "comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", "eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", - "home_temp": "[%key:common::state::home%]", "sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]", "activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]" } diff --git a/homeassistant/components/geofency/strings.json b/homeassistant/components/geofency/strings.json index 1ce926c3d2f..aa1b51697bf 100644 --- a/homeassistant/components/geofency/strings.json +++ b/homeassistant/components/geofency/strings.json @@ -2,8 +2,8 @@ "config": { "step": { "user": { - "title": "Set up the Geofency Webhook", - "description": "Are you sure you want to set up the Geofency Webhook?" + "title": "Set up the Geofency webhook", + "description": "Are you sure you want to set up the Geofency webhook?" } }, "abort": { diff --git a/homeassistant/components/goodwe/strings.json b/homeassistant/components/goodwe/strings.json index ec4ea80e22a..6348da45618 100644 --- a/homeassistant/components/goodwe/strings.json +++ b/homeassistant/components/goodwe/strings.json @@ -36,7 +36,7 @@ "name": "Inverter operation mode", "state": { "general": "General mode", - "off_grid": "Off grid mode", + "off_grid": "Off-grid mode", "backup": "Backup mode", "eco": "Eco mode", "peak_shaving": "Peak shaving mode", diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index 8ae09b58957..add75f5e95b 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -197,7 +197,12 @@ class OAuth2FlowHandler( "Error reading primary calendar, make sure Google Calendar API is enabled: %s", err, ) - return self.async_abort(reason="api_disabled") + return self.async_abort( + reason="calendar_api_disabled", + description_placeholders={ + "calendar_api_url": "https://console.cloud.google.com/apis/library/calendar-json.googleapis.com" + }, + ) except ApiException as err: _LOGGER.error("Error reading primary calendar: %s", err) return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 2bedc7a3163..32af3e675b3 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.1.0"] + "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.2.0"] } diff --git a/homeassistant/components/google/quality_scale.yaml b/homeassistant/components/google/quality_scale.yaml new file mode 100644 index 00000000000..9ef6abdba90 --- /dev/null +++ b/homeassistant/components/google/quality_scale.yaml @@ -0,0 +1,119 @@ +rules: + # Bronze + config-flow: + status: todo + comment: Some fields missing data_description in the option flow. + brands: done + dependency-transparency: + status: todo + comment: | + This depends on the legacy (deprecated) oauth libraries for device + auth (no longer recommended auth). Google publishes to pypi using + an internal build system. We need to either revisit approach or + revisit our stance on this. + common-modules: done + has-entity-name: done + action-setup: + status: todo + comment: | + Actions are current setup in `async_setup_entry` and need to be moved + to `async_setup`. + appropriate-polling: done + test-before-configure: done + entity-event-setup: + status: exempt + comment: Integration does not subscribe to events. + unique-config-entry: done + entity-unique-id: done + docs-installation-instructions: done + docs-removal-instructions: todo + test-before-setup: + status: todo + comment: | + The integration does not test the connection in `async_setup_entry` but + instead does this in the calendar platform only, which can be improved. + docs-high-level-description: done + config-flow-test-coverage: + status: todo + comment: | + The config flow has 100% test coverage, however there are opportunities + to increase functionality such as checking for the specific contents + of a unique id assigned to a config entry. + docs-actions: done + runtime-data: + status: todo + comment: | + The integration stores config entry data in `hass.data` and should be + updated to use `runtime_data`. + + # Silver + log-when-unavailable: done + config-entry-unloading: done + reauthentication-flow: + status: todo + comment: | + The integration supports reauthentication, however the config flow test + coverage can be improved on reauth corner cases. + action-exceptions: done + docs-installation-parameters: todo + integration-owner: done + parallel-updates: todo + test-coverage: + status: todo + comment: One module needs an additional line of coverage to be above the bar + docs-configuration-parameters: todo + entity-unavailable: done + + # Gold + docs-examples: done + discovery-update-info: + status: exempt + comment: Google calendar does not support discovery + entity-device-class: todo + entity-translations: todo + docs-data-update: todo + entity-disabled-by-default: done + discovery: + status: exempt + comment: Google calendar does not support discovery + exception-translations: todo + devices: todo + docs-supported-devices: done + icon-translations: + status: exempt + comment: Google calendar does not have any icons + docs-known-limitations: todo + stale-devices: + status: exempt + comment: Google calendar does not have devices + docs-supported-functions: done + repair-issues: + status: todo + comment: There are some warnings/deprecations that should be repair issues + reconfiguration-flow: + status: exempt + comment: There is nothing to configure in the configuration flow + entity-category: + status: exempt + comment: The entities in google calendar do not support categories + dynamic-devices: + status: exempt + comment: Google calendar does not have devices + docs-troubleshooting: todo + diagnostics: todo + docs-use-cases: todo + + # Platinum + async-dependency: + status: done + comment: | + The main client `gcal_sync` library is async. The primary authentication + used in config flow is handled by built in async OAuth code. The + integration still supports legacy OAuth credentials setup in the + configuration flow, which is no longer recommended or described in the + documentation for new users. This legacy config flow uses oauth2client + which is not natively async. + strict-typing: + status: todo + comment: Dependency oauth2client does not confirm to PEP 561 + inject-websession: done diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index 5776fd0480b..4f3e27af27e 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -28,7 +28,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", "code_expired": "Authentication code expired or credential setup is invalid, please try again.", - "api_disabled": "You must enable the Google Calendar API in the Google Cloud Console" + "calendar_api_disabled": "You must [enable the Google Calendar API]({calendar_api_url}) in the Google Cloud Console" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_cloud/manifest.json b/homeassistant/components/google_cloud/manifest.json index 3e08b6254db..3e6371cbe23 100644 --- a/homeassistant/components/google_cloud/manifest.json +++ b/homeassistant/components/google_cloud/manifest.json @@ -8,7 +8,7 @@ "integration_type": "service", "iot_class": "cloud_push", "requirements": [ - "google-cloud-texttospeech==2.17.2", - "google-cloud-speech==2.27.0" + "google-cloud-texttospeech==2.25.1", + "google-cloud-speech==2.31.1" ] } diff --git a/homeassistant/components/google_gemini/__init__.py b/homeassistant/components/google_gemini/__init__.py new file mode 100644 index 00000000000..b0ecda85e6b --- /dev/null +++ b/homeassistant/components/google_gemini/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Google Gemini.""" diff --git a/homeassistant/components/google_gemini/manifest.json b/homeassistant/components/google_gemini/manifest.json new file mode 100644 index 00000000000..783a6210a38 --- /dev/null +++ b/homeassistant/components/google_gemini/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "google_gemini", + "name": "Google Gemini", + "integration_type": "virtual", + "supported_by": "google_generative_ai_conversation" +} diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 88a51446cda..79d092a60c3 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -2,11 +2,13 @@ from __future__ import annotations +import asyncio import mimetypes from pathlib import Path from google.genai import Client from google.genai.errors import APIError, ClientError +from google.genai.types import File, FileState from requests.exceptions import Timeout import voluptuous as vol @@ -32,6 +34,8 @@ from .const import ( CONF_CHAT_MODEL, CONF_PROMPT, DOMAIN, + FILE_POLLING_INTERVAL_SECONDS, + LOGGER, RECOMMENDED_CHAT_MODEL, TIMEOUT_MILLIS, ) @@ -91,8 +95,40 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) prompt_parts.append(uploaded_file) + async def wait_for_file_processing(uploaded_file: File) -> None: + """Wait for file processing to complete.""" + while True: + uploaded_file = await client.aio.files.get( + name=uploaded_file.name, + config={"http_options": {"timeout": TIMEOUT_MILLIS}}, + ) + if uploaded_file.state not in ( + FileState.STATE_UNSPECIFIED, + FileState.PROCESSING, + ): + break + LOGGER.debug( + "Waiting for file `%s` to be processed, current state: %s", + uploaded_file.name, + uploaded_file.state, + ) + await asyncio.sleep(FILE_POLLING_INTERVAL_SECONDS) + + if uploaded_file.state == FileState.FAILED: + raise HomeAssistantError( + f"File `{uploaded_file.name}` processing failed, reason: {uploaded_file.error.message}" + ) + await hass.async_add_executor_job(append_files_to_prompt) + tasks = [ + asyncio.create_task(wait_for_file_processing(part)) + for part in prompt_parts + if isinstance(part, File) and part.state != FileState.ACTIVE + ] + async with asyncio.timeout(TIMEOUT_MILLIS / 1000): + await asyncio.gather(*tasks) + try: response = await client.aio.models.generate_content( model=RECOMMENDED_CHAT_MODEL, contents=prompt_parts diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index ee980c9bf48..551f9b0c9de 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -183,10 +183,10 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow): if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: - if user_input[CONF_LLM_HASS_API] == "none": - user_input.pop(CONF_LLM_HASS_API) + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) if not ( - user_input.get(CONF_LLM_HASS_API, "none") != "none" + user_input.get(CONF_LLM_HASS_API) and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True ): # Don't allow to save options that enable the Google Seearch tool with an Assist API @@ -208,23 +208,21 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow): async def google_generative_ai_config_option_schema( hass: HomeAssistant, - options: dict[str, Any] | MappingProxyType[str, Any], + options: Mapping[str, Any], genai_client: genai.Client, ) -> dict: """Return a schema for Google Generative AI completion options.""" hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label="No control", - value="none", - ) - ] - hass_apis.extend( SelectOptionDict( label=api.name, value=api.id, ) for api in llm.async_get_apis(hass) - ) + ] + if (suggested_llm_apis := options.get(CONF_LLM_HASS_API)) and isinstance( + suggested_llm_apis, str + ): + suggested_llm_apis = [suggested_llm_apis] schema = { vol.Optional( @@ -237,9 +235,8 @@ async def google_generative_ai_config_option_schema( ): TemplateSelector(), vol.Optional( CONF_LLM_HASS_API, - description={"suggested_value": options.get(CONF_LLM_HASS_API)}, - default="none", - ): SelectSelector(SelectSelectorConfig(options=hass_apis)), + description={"suggested_value": suggested_llm_apis}, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), vol.Required( CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) ): bool, diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 108ffe1891d..239b3ff763e 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -16,7 +16,7 @@ RECOMMENDED_TOP_P = 0.95 CONF_TOP_K = "top_k" RECOMMENDED_TOP_K = 64 CONF_MAX_TOKENS = "max_tokens" -RECOMMENDED_MAX_TOKENS = 150 +RECOMMENDED_MAX_TOKENS = 1500 CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold" CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold" CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold" @@ -26,3 +26,4 @@ CONF_USE_GOOGLE_SEARCH_TOOL = "enable_google_search_tool" RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False TIMEOUT_MILLIS = 10000 +FILE_POLLING_INTERVAL_SECONDS = 0.05 diff --git a/homeassistant/components/google_travel_time/__init__.py b/homeassistant/components/google_travel_time/__init__.py index 4ee9d53cf3b..1f999bbc9d0 100644 --- a/homeassistant/components/google_travel_time/__init__.py +++ b/homeassistant/components/google_travel_time/__init__.py @@ -1,11 +1,18 @@ """The google_travel_time component.""" +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .const import CONF_TIME PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google Maps Travel Time from a config entry.""" @@ -16,3 +23,37 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate an old config entry.""" + + if config_entry.version == 1: + _LOGGER.debug( + "Migrating from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + options = dict(config_entry.options) + if options.get(CONF_TIME) == "now": + options[CONF_TIME] = None + elif options.get(CONF_TIME) is not None: + if dt_util.parse_time(options[CONF_TIME]) is None: + try: + from_timestamp = dt_util.utc_from_timestamp(int(options[CONF_TIME])) + options[CONF_TIME] = ( + f"{from_timestamp.time().hour:02}:{from_timestamp.time().minute:02}" + ) + except ValueError: + _LOGGER.error( + "Invalid time format found while migrating: %s. The old config never worked. Reset to default (empty)", + options[CONF_TIME], + ) + options[CONF_TIME] = None + hass.config_entries.async_update_entry(config_entry, options=options, version=2) + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + return True diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index a29d3d75b3e..24ea29aef03 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -19,6 +19,7 @@ from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TimeSelector, ) from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -106,7 +107,7 @@ OPTIONS_SCHEMA = vol.Schema( translation_key=CONF_TIME_TYPE, ) ), - vol.Optional(CONF_TIME, default=""): cv.string, + vol.Optional(CONF_TIME): TimeSelector(), vol.Optional(CONF_TRAFFIC_MODEL): SelectSelector( SelectSelectorConfig( options=TRAFFIC_MODELS, @@ -181,8 +182,7 @@ async def validate_input( ) -> dict[str, str] | None: """Validate the user input allows us to connect.""" try: - await hass.async_add_executor_job( - validate_config_entry, + await validate_config_entry( hass, user_input[CONF_API_KEY], user_input[CONF_ORIGIN], @@ -201,7 +201,7 @@ async def validate_input( class GoogleTravelTimeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Google Maps Travel Time.""" - VERSION = 1 + VERSION = 2 @staticmethod @callback diff --git a/homeassistant/components/google_travel_time/const.py b/homeassistant/components/google_travel_time/const.py index 046e52095c0..5452e993497 100644 --- a/homeassistant/components/google_travel_time/const.py +++ b/homeassistant/components/google_travel_time/const.py @@ -1,5 +1,12 @@ """Constants for Google Travel Time.""" +from google.maps.routing_v2 import ( + RouteTravelMode, + TrafficModel, + TransitPreferences, + Units, +) + DOMAIN = "google_travel_time" ATTRIBUTION = "Powered by Google" @@ -7,7 +14,6 @@ ATTRIBUTION = "Powered by Google" CONF_DESTINATION = "destination" CONF_OPTIONS = "options" CONF_ORIGIN = "origin" -CONF_TRAVEL_MODE = "travel_mode" CONF_AVOID = "avoid" CONF_UNITS = "units" CONF_ARRIVAL_TIME = "arrival_time" @@ -79,11 +85,37 @@ ALL_LANGUAGES = [ AVOID_OPTIONS = ["tolls", "highways", "ferries", "indoor"] TRANSIT_PREFS = ["less_walking", "fewer_transfers"] +TRANSIT_PREFS_TO_GOOGLE_SDK_ENUM = { + "less_walking": TransitPreferences.TransitRoutingPreference.LESS_WALKING, + "fewer_transfers": TransitPreferences.TransitRoutingPreference.FEWER_TRANSFERS, +} TRANSPORT_TYPES = ["bus", "subway", "train", "tram", "rail"] +TRANSPORT_TYPES_TO_GOOGLE_SDK_ENUM = { + "bus": TransitPreferences.TransitTravelMode.BUS, + "subway": TransitPreferences.TransitTravelMode.SUBWAY, + "train": TransitPreferences.TransitTravelMode.TRAIN, + "tram": TransitPreferences.TransitTravelMode.LIGHT_RAIL, + "rail": TransitPreferences.TransitTravelMode.RAIL, +} TRAVEL_MODES = ["driving", "walking", "bicycling", "transit"] +TRAVEL_MODES_TO_GOOGLE_SDK_ENUM = { + "driving": RouteTravelMode.DRIVE, + "walking": RouteTravelMode.WALK, + "bicycling": RouteTravelMode.BICYCLE, + "transit": RouteTravelMode.TRANSIT, +} TRAFFIC_MODELS = ["best_guess", "pessimistic", "optimistic"] +TRAFFIC_MODELS_TO_GOOGLE_SDK_ENUM = { + "best_guess": TrafficModel.BEST_GUESS, + "pessimistic": TrafficModel.PESSIMISTIC, + "optimistic": TrafficModel.OPTIMISTIC, +} # googlemaps library uses "metric" or "imperial" terminology in distance_matrix UNITS_METRIC = "metric" UNITS_IMPERIAL = "imperial" UNITS = [UNITS_METRIC, UNITS_IMPERIAL] +UNITS_TO_GOOGLE_SDK_ENUM = { + UNITS_METRIC: Units.METRIC, + UNITS_IMPERIAL: Units.IMPERIAL, +} diff --git a/homeassistant/components/google_travel_time/helpers.py b/homeassistant/components/google_travel_time/helpers.py index baceffecc73..49294455a49 100644 --- a/homeassistant/components/google_travel_time/helpers.py +++ b/homeassistant/components/google_travel_time/helpers.py @@ -2,41 +2,80 @@ import logging -from googlemaps import Client -from googlemaps.distance_matrix import distance_matrix -from googlemaps.exceptions import ApiError, Timeout, TransportError +from google.api_core.client_options import ClientOptions +from google.api_core.exceptions import ( + Forbidden, + GatewayTimeout, + GoogleAPIError, + Unauthorized, +) +from google.maps.routing_v2 import ( + ComputeRoutesRequest, + Location, + RoutesAsyncClient, + RouteTravelMode, + Waypoint, +) +from google.type import latlng_pb2 +import voluptuous as vol from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.location import find_coordinates _LOGGER = logging.getLogger(__name__) -def validate_config_entry( +def convert_to_waypoint(hass: HomeAssistant, location: str) -> Waypoint | None: + """Convert a location to a Waypoint. + + Will either use coordinates or if none are found, use the location as an address. + """ + coordinates = find_coordinates(hass, location) + if coordinates is None: + return None + try: + formatted_coordinates = coordinates.split(",") + vol.Schema(cv.gps(formatted_coordinates)) + except (AttributeError, vol.ExactSequenceInvalid): + return Waypoint(address=location) + return Waypoint( + location=Location( + lat_lng=latlng_pb2.LatLng( + latitude=float(formatted_coordinates[0]), + longitude=float(formatted_coordinates[1]), + ) + ) + ) + + +async def validate_config_entry( hass: HomeAssistant, api_key: str, origin: str, destination: str ) -> None: """Return whether the config entry data is valid.""" - resolved_origin = find_coordinates(hass, origin) - resolved_destination = find_coordinates(hass, destination) + resolved_origin = convert_to_waypoint(hass, origin) + resolved_destination = convert_to_waypoint(hass, destination) + client_options = ClientOptions(api_key=api_key) + client = RoutesAsyncClient(client_options=client_options) + field_mask = "routes.duration" + request = ComputeRoutesRequest( + origin=resolved_origin, + destination=resolved_destination, + travel_mode=RouteTravelMode.DRIVE, + ) try: - client = Client(api_key, timeout=10) - except ValueError as value_error: - _LOGGER.error("Malformed API key") - raise InvalidApiKeyException from value_error - try: - distance_matrix(client, resolved_origin, resolved_destination, mode="driving") - except ApiError as api_error: - if api_error.status == "REQUEST_DENIED": - _LOGGER.error("Request denied: %s", api_error.message) - raise InvalidApiKeyException from api_error - _LOGGER.error("Unknown error: %s", api_error.message) - raise UnknownException from api_error - except TransportError as transport_error: - _LOGGER.error("Unknown error: %s", transport_error) - raise UnknownException from transport_error - except Timeout as timeout_error: + await client.compute_routes( + request, metadata=[("x-goog-fieldmask", field_mask)] + ) + except (Unauthorized, Forbidden) as unauthorized_error: + _LOGGER.error("Request denied: %s", unauthorized_error.message) + raise InvalidApiKeyException from unauthorized_error + except GatewayTimeout as timeout_error: _LOGGER.error("Timeout error") raise TimeoutError from timeout_error + except GoogleAPIError as unknown_error: + _LOGGER.error("Unknown error: %s", unknown_error) + raise UnknownException from unknown_error class InvalidApiKeyException(Exception): diff --git a/homeassistant/components/google_travel_time/manifest.json b/homeassistant/components/google_travel_time/manifest.json index d7c98478272..6d69c908d59 100644 --- a/homeassistant/components/google_travel_time/manifest.json +++ b/homeassistant/components/google_travel_time/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/google_travel_time", "iot_class": "cloud_polling", - "loggers": ["googlemaps", "homeassistant.helpers.location"], - "requirements": ["googlemaps==2.5.1"] + "loggers": ["google", "homeassistant.helpers.location"], + "requirements": ["google-maps-routing==0.6.14"] } diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index cac792dca53..7448fc1cb09 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -2,12 +2,22 @@ from __future__ import annotations -from datetime import datetime, timedelta +import datetime import logging +from typing import TYPE_CHECKING, Any -from googlemaps import Client -from googlemaps.distance_matrix import distance_matrix -from googlemaps.exceptions import ApiError, Timeout, TransportError +from google.api_core.client_options import ClientOptions +from google.api_core.exceptions import GoogleAPIError +from google.maps.routing_v2 import ( + ComputeRoutesRequest, + Route, + RouteModifiers, + RoutesAsyncClient, + RouteTravelMode, + RoutingPreference, + TransitPreferences, +) +from google.protobuf import timestamp_pb2 from homeassistant.components.sensor import ( SensorDeviceClass, @@ -17,6 +27,8 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, + CONF_LANGUAGE, + CONF_MODE, CONF_NAME, EVENT_HOMEASSISTANT_STARTED, UnitOfTime, @@ -30,26 +42,49 @@ from homeassistant.util import dt as dt_util from .const import ( ATTRIBUTION, CONF_ARRIVAL_TIME, + CONF_AVOID, CONF_DEPARTURE_TIME, CONF_DESTINATION, CONF_ORIGIN, + CONF_TRAFFIC_MODEL, + CONF_TRANSIT_MODE, + CONF_TRANSIT_ROUTING_PREFERENCE, + CONF_UNITS, DEFAULT_NAME, DOMAIN, + TRAFFIC_MODELS_TO_GOOGLE_SDK_ENUM, + TRANSIT_PREFS_TO_GOOGLE_SDK_ENUM, + TRANSPORT_TYPES_TO_GOOGLE_SDK_ENUM, + TRAVEL_MODES_TO_GOOGLE_SDK_ENUM, + UNITS_TO_GOOGLE_SDK_ENUM, ) +from .helpers import convert_to_waypoint _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(minutes=5) +SCAN_INTERVAL = datetime.timedelta(minutes=10) +FIELD_MASK = "routes.duration,routes.localized_values" -def convert_time_to_utc(timestr): - """Take a string like 08:00:00 and convert it to a unix timestamp.""" - combined = datetime.combine( - dt_util.start_of_local_day(), dt_util.parse_time(timestr) +def convert_time(time_str: str) -> timestamp_pb2.Timestamp | None: + """Convert a string like '08:00' to a google pb2 Timestamp. + + If the time is in the past, it will be shifted to the next day. + """ + parsed_time = dt_util.parse_time(time_str) + if TYPE_CHECKING: + assert parsed_time is not None + start_of_day = dt_util.start_of_local_day() + combined = datetime.datetime.combine( + start_of_day, + parsed_time, + start_of_day.tzinfo, ) - if combined < datetime.now(): - combined = combined + timedelta(days=1) - return dt_util.as_timestamp(combined) + if combined < dt_util.now(): + combined = combined + datetime.timedelta(days=1) + timestamp = timestamp_pb2.Timestamp() + timestamp.FromDatetime(dt=combined) + return timestamp async def async_setup_entry( @@ -63,7 +98,8 @@ async def async_setup_entry( destination = config_entry.data[CONF_DESTINATION] name = config_entry.data.get(CONF_NAME, DEFAULT_NAME) - client = Client(api_key, timeout=10) + client_options = ClientOptions(api_key=api_key) + client = RoutesAsyncClient(client_options=client_options) sensor = GoogleTravelTimeSensor( config_entry, name, api_key, origin, destination, client @@ -80,7 +116,15 @@ class GoogleTravelTimeSensor(SensorEntity): _attr_device_class = SensorDeviceClass.DURATION _attr_state_class = SensorStateClass.MEASUREMENT - def __init__(self, config_entry, name, api_key, origin, destination, client): + def __init__( + self, + config_entry: ConfigEntry, + name: str, + api_key: str, + origin: str, + destination: str, + client: RoutesAsyncClient, + ) -> None: """Initialize the sensor.""" self._attr_name = name self._attr_unique_id = config_entry.entry_id @@ -91,13 +135,12 @@ class GoogleTravelTimeSensor(SensorEntity): ) self._config_entry = config_entry - self._matrix = None - self._api_key = api_key + self._route: Route | None = None self._client = client self._origin = origin self._destination = destination - self._resolved_origin = None - self._resolved_destination = None + self._resolved_origin: str | None = None + self._resolved_destination: str | None = None async def async_added_to_hass(self) -> None: """Handle when entity is added.""" @@ -109,77 +152,127 @@ class GoogleTravelTimeSensor(SensorEntity): await self.first_update() @property - def native_value(self): + def native_value(self) -> float | None: """Return the state of the sensor.""" - if self._matrix is None: + if self._route is None: return None - _data = self._matrix["rows"][0]["elements"][0] - if "duration_in_traffic" in _data: - return round(_data["duration_in_traffic"]["value"] / 60) - if "duration" in _data: - return round(_data["duration"]["value"] / 60) - return None + return round(self._route.duration.seconds / 60) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" - if self._matrix is None: + if self._route is None: return None - res = self._matrix.copy() - options = self._config_entry.options.copy() - res.update(options) - del res["rows"] - _data = self._matrix["rows"][0]["elements"][0] - if "duration_in_traffic" in _data: - res["duration_in_traffic"] = _data["duration_in_traffic"]["text"] - if "duration" in _data: - res["duration"] = _data["duration"]["text"] - if "distance" in _data: - res["distance"] = _data["distance"]["text"] - res["origin"] = self._resolved_origin - res["destination"] = self._resolved_destination - return res + result = self._config_entry.options.copy() + result["duration_in_traffic"] = self._route.localized_values.duration.text + result["duration"] = self._route.localized_values.static_duration.text + result["distance"] = self._route.localized_values.distance.text - async def first_update(self, _=None): + result["origin"] = self._resolved_origin + result["destination"] = self._resolved_destination + return result + + async def first_update(self, _=None) -> None: """Run the first update and write the state.""" - await self.hass.async_add_executor_job(self.update) + await self.async_update() self.async_write_ha_state() - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data from Google.""" - options_copy = self._config_entry.options.copy() - dtime = options_copy.get(CONF_DEPARTURE_TIME) - atime = options_copy.get(CONF_ARRIVAL_TIME) - if dtime is not None and ":" in dtime: - options_copy[CONF_DEPARTURE_TIME] = convert_time_to_utc(dtime) - elif dtime is not None: - options_copy[CONF_DEPARTURE_TIME] = dtime - elif atime is None: - options_copy[CONF_DEPARTURE_TIME] = "now" + travel_mode = TRAVEL_MODES_TO_GOOGLE_SDK_ENUM[ + self._config_entry.options[CONF_MODE] + ] - if atime is not None and ":" in atime: - options_copy[CONF_ARRIVAL_TIME] = convert_time_to_utc(atime) - elif atime is not None: - options_copy[CONF_ARRIVAL_TIME] = atime + if ( + departure_time := self._config_entry.options.get(CONF_DEPARTURE_TIME) + ) is not None: + departure_time = convert_time(departure_time) + + if ( + arrival_time := self._config_entry.options.get(CONF_ARRIVAL_TIME) + ) is not None: + arrival_time = convert_time(arrival_time) + if travel_mode != RouteTravelMode.TRANSIT: + arrival_time = None + + traffic_model = None + routing_preference = None + route_modifiers = None + if travel_mode == RouteTravelMode.DRIVE: + if ( + options_traffic_model := self._config_entry.options.get( + CONF_TRAFFIC_MODEL + ) + ) is not None: + traffic_model = TRAFFIC_MODELS_TO_GOOGLE_SDK_ENUM[options_traffic_model] + routing_preference = RoutingPreference.TRAFFIC_AWARE_OPTIMAL + route_modifiers = RouteModifiers( + avoid_tolls=self._config_entry.options.get(CONF_AVOID) == "tolls", + avoid_ferries=self._config_entry.options.get(CONF_AVOID) == "ferries", + avoid_highways=self._config_entry.options.get(CONF_AVOID) == "highways", + avoid_indoor=self._config_entry.options.get(CONF_AVOID) == "indoor", + ) + + transit_preferences = None + if travel_mode == RouteTravelMode.TRANSIT: + transit_routing_preference = None + transit_travel_mode = ( + TransitPreferences.TransitTravelMode.TRANSIT_TRAVEL_MODE_UNSPECIFIED + ) + if ( + option_transit_preferences := self._config_entry.options.get( + CONF_TRANSIT_ROUTING_PREFERENCE + ) + ) is not None: + transit_routing_preference = TRANSIT_PREFS_TO_GOOGLE_SDK_ENUM[ + option_transit_preferences + ] + if ( + option_transit_mode := self._config_entry.options.get(CONF_TRANSIT_MODE) + ) is not None: + transit_travel_mode = TRANSPORT_TYPES_TO_GOOGLE_SDK_ENUM[ + option_transit_mode + ] + transit_preferences = TransitPreferences( + routing_preference=transit_routing_preference, + allowed_travel_modes=[transit_travel_mode], + ) + + language = None + if ( + options_language := self._config_entry.options.get(CONF_LANGUAGE) + ) is not None: + language = options_language self._resolved_origin = find_coordinates(self.hass, self._origin) self._resolved_destination = find_coordinates(self.hass, self._destination) - _LOGGER.debug( "Getting update for origin: %s destination: %s", self._resolved_origin, self._resolved_destination, ) if self._resolved_destination is not None and self._resolved_origin is not None: + request = ComputeRoutesRequest( + origin=convert_to_waypoint(self.hass, self._resolved_origin), + destination=convert_to_waypoint(self.hass, self._resolved_destination), + travel_mode=travel_mode, + routing_preference=routing_preference, + departure_time=departure_time, + arrival_time=arrival_time, + route_modifiers=route_modifiers, + language_code=language, + units=UNITS_TO_GOOGLE_SDK_ENUM[self._config_entry.options[CONF_UNITS]], + traffic_model=traffic_model, + transit_preferences=transit_preferences, + ) try: - self._matrix = distance_matrix( - self._client, - self._resolved_origin, - self._resolved_destination, - **options_copy, + response = await self._client.compute_routes( + request, metadata=[("x-goog-fieldmask", FIELD_MASK)] ) - except (ApiError, TransportError, Timeout) as ex: + if response is not None and len(response.routes) > 0: + self._route = response.routes[0] + except GoogleAPIError as ex: _LOGGER.error("Error getting travel time: %s", ex) - self._matrix = None + self._route = None diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index 765cfc9c4b6..87bc09eb456 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates, or a Google place ID. When specifying the location using a Google place ID, the ID must be prefixed with `place_id:`.", + "description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates or an entity ID that provides this information in its state, an entity ID with latitude and longitude attributes, or zone friendly name (case sensitive)", "data": { "name": "[%key:common::config_flow::data::name%]", "api_key": "[%key:common::config_flow::data::api_key%]", @@ -33,16 +33,16 @@ "options": { "step": { "init": { - "description": "You can optionally specify either a Departure Time or Arrival Time. If specifying a departure time, you can enter `now`, a Unix timestamp, or a 24 hour time string like `08:00:00`. If specifying an arrival time, you can use a Unix timestamp or a 24 hour time string like `08:00:00`", + "description": "You can optionally specify either a departure time or arrival time in the form of a 24 hour time string like `08:00:00`", "data": { - "mode": "Travel Mode", + "mode": "Travel mode", "language": "[%key:common::config_flow::data::language%]", - "time_type": "Time Type", + "time_type": "Time type", "time": "Time", "avoid": "Avoid", - "traffic_model": "Traffic Model", - "transit_mode": "Transit Mode", - "transit_routing_preference": "Transit Routing Preference", + "traffic_model": "Traffic model", + "transit_mode": "Transit mode", + "transit_routing_preference": "Transit routing preference", "units": "Units" } } @@ -68,19 +68,19 @@ }, "units": { "options": { - "metric": "Metric System", - "imperial": "Imperial System" + "metric": "Metric system", + "imperial": "Imperial system" } }, "time_type": { "options": { - "arrival_time": "Arrival Time", - "departure_time": "Departure Time" + "arrival_time": "Arrival time", + "departure_time": "Departure time" } }, "traffic_model": { "options": { - "best_guess": "Best Guess", + "best_guess": "Best guess", "pessimistic": "Pessimistic", "optimistic": "Optimistic" } @@ -96,8 +96,8 @@ }, "transit_routing_preference": { "options": { - "less_walking": "Less Walking", - "fewer_transfers": "Fewer Transfers" + "less_walking": "Less walking", + "fewer_transfers": "Fewer transfers" } } } diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index b06dab243af..93f90e36876 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -50,6 +50,10 @@ "local_name": "GVH5130*", "connectable": false }, + { + "local_name": "GVH5110*", + "connectable": false + }, { "manufacturer_id": 1, "service_uuid": "0000ec88-0000-1000-8000-00805f9b34fb", @@ -135,5 +139,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.43.1"] + "requirements": ["govee-ble==0.44.0"] } diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py index 984654477e9..c5c8ed42ad5 100644 --- a/homeassistant/components/govee_light_local/light.py +++ b/homeassistant/components/govee_light_local/light.py @@ -157,9 +157,6 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - if not self.is_on or not kwargs: - await self.coordinator.turn_on(self._device) - if ATTR_BRIGHTNESS in kwargs: brightness: int = int((float(kwargs[ATTR_BRIGHTNESS]) / 255.0) * 100.0) await self.coordinator.set_brightness(self._device, brightness) @@ -187,6 +184,9 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): self._save_last_color_state() await self.coordinator.set_scene(self._device, effect) + if not self.is_on or not kwargs: + await self.coordinator.turn_on(self._device) + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/gpslogger/strings.json b/homeassistant/components/gpslogger/strings.json index a946574f8b8..3238d6f460e 100644 --- a/homeassistant/components/gpslogger/strings.json +++ b/homeassistant/components/gpslogger/strings.json @@ -2,8 +2,8 @@ "config": { "step": { "user": { - "title": "Set up the GPSLogger Webhook", - "description": "Are you sure you want to set up the GPSLogger Webhook?" + "title": "Set up the GPSLogger webhook", + "description": "Are you sure you want to set up the GPSLogger webhook?" } }, "abort": { diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index fb90eb9b22c..b80b78027bf 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Create Group", + "title": "Create group", "description": "Groups allow you to create a new entity that represents multiple entities of the same type.", "menu_options": { "binary_sensor": "Binary sensor group", @@ -104,7 +104,7 @@ "round_digits": "Round value to number of decimals", "device_class": "Device class", "state_class": "State class", - "unit_of_measurement": "Unit of Measurement" + "unit_of_measurement": "Unit of measurement" } }, "switch": { diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index 758428d7a55..256efea447d 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -164,7 +164,7 @@ "name": "Load consumption today (solar)" }, "mix_self_consumption_today": { - "name": "Self consumption today (solar + battery)" + "name": "Self-consumption today (solar + battery)" }, "mix_load_consumption_battery_today": { "name": "Load consumption today (battery)" @@ -173,7 +173,7 @@ "name": "Import from grid today (load)" }, "mix_last_update": { - "name": "Last Data Update" + "name": "Last data update" }, "mix_import_from_grid_today_combined": { "name": "Import from grid today (load + charging)" diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index d71b2b85f7b..eeeedff00bb 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -51,11 +51,9 @@ from homeassistant.helpers.hassio import ( get_supervisor_ip as _get_supervisor_ip, is_hassio as _is_hassio, ) -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service_info.hassio import ( HassioServiceInfo as _HassioServiceInfo, ) -from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.async_ import create_eager_task @@ -78,6 +76,7 @@ from . import ( # noqa: F401 from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState # noqa: F401 from .addon_panel import async_setup_addon_panel from .auth import async_setup_auth_view +from .config import HassioConfig from .const import ( ADDONS_COORDINATOR, ATTR_ADDON, @@ -91,6 +90,7 @@ from .const import ( ATTR_PASSWORD, ATTR_SLUG, DATA_COMPONENT, + DATA_CONFIG_STORE, DATA_CORE_INFO, DATA_HOST_INFO, DATA_INFO, @@ -104,7 +104,6 @@ from .const import ( ) from .coordinator import ( HassioDataUpdateCoordinator, - get_addons_changelogs, # noqa: F401 get_addons_info, get_addons_stats, # noqa: F401 get_core_info, # noqa: F401 @@ -144,8 +143,6 @@ _DEPRECATED_HassioServiceInfo = DeprecatedConstant( "2025.11", ) -STORAGE_KEY = DOMAIN -STORAGE_VERSION = 1 # If new platforms are added, be sure to import them above # so we do not make other components that depend on hassio # wait for the import of the platforms @@ -161,7 +158,6 @@ CONFIG_SCHEMA = vol.Schema( SERVICE_ADDON_START = "addon_start" SERVICE_ADDON_STOP = "addon_stop" SERVICE_ADDON_RESTART = "addon_restart" -SERVICE_ADDON_UPDATE = "addon_update" SERVICE_ADDON_STDIN = "addon_stdin" SERVICE_HOST_SHUTDOWN = "host_shutdown" SERVICE_HOST_REBOOT = "host_reboot" @@ -242,7 +238,6 @@ MAP_SERVICE_API = { SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON), SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON), SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON), - SERVICE_ADDON_UPDATE: APIEndpointSettings("/addons/{addon}/update", SCHEMA_ADDON), SERVICE_ADDON_STDIN: APIEndpointSettings( "/addons/{addon}/stdin", SCHEMA_ADDON_STDIN ), @@ -335,13 +330,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: except SupervisorError: _LOGGER.warning("Not connected with the supervisor / system too busy!") - store = Store[dict[str, str]](hass, STORAGE_VERSION, STORAGE_KEY) - if (data := await store.async_load()) is None: - data = {} + # Load the store + config_store = HassioConfig(hass) + await config_store.load() + hass.data[DATA_CONFIG_STORE] = config_store refresh_token = None - if "hassio_user" in data: - user = await hass.auth.async_get_user(data["hassio_user"]) + if (hassio_user := config_store.data.hassio_user) is not None: + user = await hass.auth.async_get_user(hassio_user) if user and user.refresh_tokens: refresh_token = list(user.refresh_tokens.values())[0] @@ -358,8 +354,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] ) refresh_token = await hass.auth.async_create_refresh_token(user) - data["hassio_user"] = user.id - await store.async_save(data) + config_store.update(hassio_user=user.id) # This overrides the normal API call that would be forwarded development_repo = config.get(DOMAIN, {}).get(CONF_FRONTEND_REPO) @@ -390,18 +385,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) last_timezone = None + last_country = None async def push_config(_: Event | None) -> None: """Push core config to Hass.io.""" nonlocal last_timezone + nonlocal last_country new_timezone = str(hass.config.time_zone) + new_country = str(hass.config.country) - if new_timezone == last_timezone: - return - - last_timezone = new_timezone - await hassio.update_hass_timezone(new_timezone) + if new_timezone != last_timezone or new_country != last_country: + last_timezone = new_timezone + last_country = new_country + await hassio.update_hass_config(new_timezone, new_country) hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config) @@ -412,16 +409,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: async def async_service_handler(service: ServiceCall) -> None: """Handle service calls for Hass.io.""" - if service.service == SERVICE_ADDON_UPDATE: - async_create_issue( - hass, - DOMAIN, - "update_service_deprecated", - breaks_in_ha_version="2025.5", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="update_service_deprecated", - ) api_endpoint = MAP_SERVICE_API[service.service] data = service.data.copy() diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 20f1ec82a7a..38bf3c82561 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -57,7 +57,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum -from .const import DOMAIN, EVENT_SUPERVISOR_EVENT +from .const import DATA_CONFIG_STORE, DOMAIN, EVENT_SUPERVISOR_EVENT from .handler import get_supervisor_client MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") @@ -729,6 +729,18 @@ async def backup_addon_before_update( if backup.extra_metadata.get(TAG_ADDON_UPDATE) == addon } + def _delete_filter( + backups: dict[str, ManagerBackup], + ) -> dict[str, ManagerBackup]: + """Return oldest backups more numerous than copies to delete.""" + update_config = hass.data[DATA_CONFIG_STORE].data.update_config + return dict( + sorted( + backups.items(), + key=lambda backup_item: backup_item[1].date, + )[: max(len(backups) - update_config.add_on_backup_retain_copies, 0)] + ) + try: await backup_manager.async_create_backup( agent_ids=[await _default_agent(client)], @@ -747,7 +759,7 @@ async def backup_addon_before_update( try: await backup_manager.async_delete_filtered_backups( include_filter=addon_update_backup_filter, - delete_filter=lambda backups: backups, + delete_filter=_delete_filter, ) except BackupManagerError as err: raise HomeAssistantError(f"Error deleting old backups: {err}") from err diff --git a/homeassistant/components/hassio/config.py b/homeassistant/components/hassio/config.py new file mode 100644 index 00000000000..f277249ee94 --- /dev/null +++ b/homeassistant/components/hassio/config.py @@ -0,0 +1,148 @@ +"""Provide persistent configuration for the hassio integration.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Required, Self, TypedDict + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import UNDEFINED, UndefinedType + +from .const import DOMAIN + +STORE_DELAY_SAVE = 30 +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +STORAGE_VERSION_MINOR = 1 + + +class HassioConfig: + """Handle update config.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize update config.""" + self.data = HassioConfigData( + hassio_user=None, + update_config=HassioUpdateConfig(), + ) + self._hass = hass + self._store = HassioConfigStore(hass, self) + + async def load(self) -> None: + """Load config.""" + if not (store_data := await self._store.load()): + return + self.data = HassioConfigData.from_dict(store_data) + + @callback + def update( + self, + *, + hassio_user: str | UndefinedType = UNDEFINED, + update_config: HassioUpdateParametersDict | UndefinedType = UNDEFINED, + ) -> None: + """Update config.""" + if hassio_user is not UNDEFINED: + self.data.hassio_user = hassio_user + if update_config is not UNDEFINED: + self.data.update_config = replace(self.data.update_config, **update_config) + + self._store.save() + + +@dataclass(kw_only=True) +class HassioConfigData: + """Represent loaded update config data.""" + + hassio_user: str | None + update_config: HassioUpdateConfig + + @classmethod + def from_dict(cls, data: StoredHassioConfig) -> Self: + """Initialize update config data from a dict.""" + if update_data := data.get("update_config"): + update_config = HassioUpdateConfig( + add_on_backup_before_update=update_data["add_on_backup_before_update"], + add_on_backup_retain_copies=update_data["add_on_backup_retain_copies"], + core_backup_before_update=update_data["core_backup_before_update"], + ) + else: + update_config = HassioUpdateConfig() + return cls( + hassio_user=data["hassio_user"], + update_config=update_config, + ) + + def to_dict(self) -> StoredHassioConfig: + """Convert update config data to a dict.""" + return StoredHassioConfig( + hassio_user=self.hassio_user, + update_config=self.update_config.to_dict(), + ) + + +@dataclass(kw_only=True) +class HassioUpdateConfig: + """Represent the backup retention configuration.""" + + add_on_backup_before_update: bool = False + add_on_backup_retain_copies: int = 1 + core_backup_before_update: bool = False + + def to_dict(self) -> StoredHassioUpdateConfig: + """Convert backup retention configuration to a dict.""" + return StoredHassioUpdateConfig( + add_on_backup_before_update=self.add_on_backup_before_update, + add_on_backup_retain_copies=self.add_on_backup_retain_copies, + core_backup_before_update=self.core_backup_before_update, + ) + + +class HassioUpdateParametersDict(TypedDict, total=False): + """Represent the parameters for update.""" + + add_on_backup_before_update: bool + add_on_backup_retain_copies: int + core_backup_before_update: bool + + +class HassioConfigStore: + """Store hassio config.""" + + def __init__(self, hass: HomeAssistant, config: HassioConfig) -> None: + """Initialize the hassio config store.""" + self._hass = hass + self._config = config + self._store: Store[StoredHassioConfig] = Store( + hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR + ) + + async def load(self) -> StoredHassioConfig | None: + """Load the store.""" + return await self._store.async_load() + + @callback + def save(self) -> None: + """Save config.""" + self._store.async_delay_save(self._data_to_save, STORE_DELAY_SAVE) + + @callback + def _data_to_save(self) -> StoredHassioConfig: + """Return data to save.""" + return self._config.data.to_dict() + + +class StoredHassioConfig(TypedDict, total=False): + """Represent the stored hassio config.""" + + hassio_user: Required[str | None] + update_config: StoredHassioUpdateConfig + + +class StoredHassioUpdateConfig(TypedDict): + """Represent the stored update config.""" + + add_on_backup_before_update: bool + add_on_backup_retain_copies: int + core_backup_before_update: bool diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index d1cda51ec7b..563b271c578 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: + from .config import HassioConfig from .handler import HassIO @@ -74,6 +75,7 @@ ADDONS_COORDINATOR = "hassio_addons_coordinator" DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN) +DATA_CONFIG_STORE: HassKey[HassioConfig] = HassKey("hassio_config_store") DATA_CORE_INFO = "hassio_core_info" DATA_CORE_STATS = "hassio_core_stats" DATA_HOST_INFO = "hassio_host_info" @@ -83,7 +85,6 @@ DATA_OS_INFO = "hassio_os_info" DATA_NETWORK_INFO = "hassio_network_info" DATA_SUPERVISOR_INFO = "hassio_supervisor_info" DATA_SUPERVISOR_STATS = "hassio_supervisor_stats" -DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs" DATA_ADDONS_INFO = "hassio_addons_info" DATA_ADDONS_STATS = "hassio_addons_stats" HASSIO_UPDATE_INTERVAL = timedelta(minutes=5) @@ -92,7 +93,6 @@ ATTR_AUTO_UPDATE = "auto_update" ATTR_VERSION = "version" ATTR_VERSION_LATEST = "version_latest" ATTR_CPU_PERCENT = "cpu_percent" -ATTR_CHANGELOG = "changelog" ATTR_LOCATION = "location" ATTR_MEMORY_PERCENT = "memory_percent" ATTR_SLUG = "slug" @@ -122,14 +122,13 @@ CORE_CONTAINER = "homeassistant" SUPERVISOR_CONTAINER = "hassio_supervisor" CONTAINER_STATS = "stats" -CONTAINER_CHANGELOG = "changelog" CONTAINER_INFO = "info" # This is a mapping of which endpoint the key in the addon data # is obtained from so we know which endpoint to update when the # coordinator polls for updates. KEY_TO_UPDATE_TYPES: dict[str, set[str]] = { - ATTR_VERSION_LATEST: {CONTAINER_INFO, CONTAINER_CHANGELOG}, + ATTR_VERSION_LATEST: {CONTAINER_INFO}, ATTR_MEMORY_PERCENT: {CONTAINER_STATS}, ATTR_CPU_PERCENT: {CONTAINER_STATS}, ATTR_VERSION: {CONTAINER_INFO}, diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index 833068a713c..1e529593f09 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -7,7 +7,7 @@ from collections import defaultdict import logging from typing import TYPE_CHECKING, Any -from aiohasupervisor import SupervisorError +from aiohasupervisor import SupervisorError, SupervisorNotFoundError from aiohasupervisor.models import StoreInfo from homeassistant.config_entries import ConfigEntry @@ -21,18 +21,15 @@ from homeassistant.loader import bind_hass from .const import ( ATTR_AUTO_UPDATE, - ATTR_CHANGELOG, ATTR_REPOSITORY, ATTR_SLUG, ATTR_STARTED, ATTR_STATE, ATTR_URL, ATTR_VERSION, - CONTAINER_CHANGELOG, CONTAINER_INFO, CONTAINER_STATS, CORE_CONTAINER, - DATA_ADDONS_CHANGELOGS, DATA_ADDONS_INFO, DATA_ADDONS_STATS, DATA_COMPONENT, @@ -155,16 +152,6 @@ def get_supervisor_stats(hass: HomeAssistant) -> dict[str, Any]: return hass.data.get(DATA_SUPERVISOR_STATS) or {} -@callback -@bind_hass -def get_addons_changelogs(hass: HomeAssistant): - """Return Addons changelogs. - - Async friendly. - """ - return hass.data.get(DATA_ADDONS_CHANGELOGS) - - @callback @bind_hass def get_os_info(hass: HomeAssistant) -> dict[str, Any] | None: @@ -337,7 +324,6 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): supervisor_info = get_supervisor_info(self.hass) or {} addons_info = get_addons_info(self.hass) or {} addons_stats = get_addons_stats(self.hass) - addons_changelogs = get_addons_changelogs(self.hass) store_data = get_store(self.hass) if store_data: @@ -355,7 +341,6 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): ATTR_AUTO_UPDATE: (addons_info.get(addon[ATTR_SLUG]) or {}).get( ATTR_AUTO_UPDATE, False ), - ATTR_CHANGELOG: (addons_changelogs or {}).get(addon[ATTR_SLUG]), ATTR_REPOSITORY: repositories.get( addon.get(ATTR_REPOSITORY), addon.get(ATTR_REPOSITORY, "") ), @@ -422,10 +407,12 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): return new_data - async def force_info_update_supervisor(self) -> None: - """Force update of the supervisor info.""" - self.hass.data[DATA_SUPERVISOR_INFO] = await self.hassio.get_supervisor_info() - await self.async_refresh() + async def get_changelog(self, addon_slug: str) -> str | None: + """Get the changelog for an add-on.""" + try: + return await self.supervisor_client.store.addon_changelog(addon_slug) + except SupervisorNotFoundError: + return None async def force_data_refresh(self, first_update: bool) -> None: """Force update of the addon info.""" @@ -475,13 +462,6 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): started_addons, False, ), - ( - DATA_ADDONS_CHANGELOGS, - self._update_addon_changelog, - CONTAINER_CHANGELOG, - all_addons, - True, - ), ( DATA_ADDONS_INFO, self._update_addon_info, @@ -513,15 +493,6 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): return (slug, None) return (slug, stats.to_dict()) - async def _update_addon_changelog(self, slug: str) -> tuple[str, str | None]: - """Return the changelog for an add-on.""" - try: - changelog = await self.supervisor_client.store.addon_changelog(slug) - except SupervisorError as err: - _LOGGER.warning("Could not fetch changelog for %s: %s", slug, err) - return (slug, None) - return (slug, changelog) - async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]: """Return the info for an add-on.""" try: diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 752f535ca04..7aec0aa7a61 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -248,12 +248,14 @@ class HassIO: return await self.send_command("/homeassistant/options", payload=options) @_api_bool - def update_hass_timezone(self, timezone: str) -> Coroutine: + def update_hass_config(self, timezone: str, country: str | None) -> Coroutine: """Update Home-Assistant timezone data on Hass.io. This method returns a coroutine. """ - return self.send_command("/supervisor/options", payload={"timezone": timezone}) + return self.send_command( + "/supervisor/options", payload={"timezone": timezone, "country": country} + ) @_api_bool def update_diagnostics(self, diagnostics: bool) -> Coroutine: diff --git a/homeassistant/components/hassio/icons.json b/homeassistant/components/hassio/icons.json index 64f032d9f80..33eb154edc4 100644 --- a/homeassistant/components/hassio/icons.json +++ b/homeassistant/components/hassio/icons.json @@ -22,9 +22,6 @@ "addon_stop": { "service": "mdi:stop" }, - "addon_update": { - "service": "mdi:update" - }, "host_reboot": { "service": "mdi:restart" }, diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 3a3eb0e945c..e673c3a70e9 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -46,6 +46,8 @@ RESPONSE_HEADERS_FILTER = { MIN_COMPRESSED_SIZE = 128 MAX_SIMPLE_RESPONSE_SIZE = 4194000 +DISABLED_TIMEOUT = ClientTimeout(total=None) + @callback def async_setup_ingress_view(hass: HomeAssistant, host: str) -> None: @@ -107,6 +109,7 @@ class HassIOIngress(HomeAssistantView): delete = _handle patch = _handle options = _handle + head = _handle async def _handle_websocket( self, request: web.Request, token: str, path: str @@ -167,7 +170,7 @@ class HassIOIngress(HomeAssistantView): params=request.query, allow_redirects=False, data=request.content if request.method != "GET" else None, - timeout=ClientTimeout(total=None), + timeout=DISABLED_TIMEOUT, skip_auto_headers={hdrs.CONTENT_TYPE}, ) as result: headers = _response_header(result) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index ad98beb5baa..a2af6fb217c 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.3.0"], + "requirements": ["aiohasupervisor==0.3.1"], "single_config_entry": true } diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index 30086e4dd2b..43143fe6889 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -30,14 +30,6 @@ addon_stop: selector: addon: -addon_update: - fields: - addon: - required: true - example: core_ssh - selector: - addon: - host_reboot: host_shutdown: backup_full: diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 71d7a6669f3..e34aa020c5a 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -24,8 +24,8 @@ "fix_menu": { "description": "Add-on {addon} is set to start at boot but failed to start. Usually this occurs when the configuration is incorrect or the same port is used in multiple add-ons. Check the configuration as well as logs for {addon} and Supervisor.\n\nUse Start to try again or Disable to turn off the start at boot option.", "menu_options": { - "addon_execute_start": "Start", - "addon_disable_boot": "Disable" + "addon_execute_start": "[%key:common::action::start%]", + "addon_disable_boot": "[%key:common::action::disable%]" } } }, @@ -225,10 +225,6 @@ "unsupported_virtualization_image": { "title": "Unsupported system - Incorrect OS image for virtualization", "description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. Use the link to learn more and how to fix this." - }, - "update_service_deprecated": { - "title": "Deprecated update add-on action", - "description": "The update add-on action has been deprecated and will be removed in 2025.5. Please use the update entity and the respective action to update the add-on instead." } }, "entity": { @@ -313,16 +309,6 @@ } } }, - "addon_update": { - "name": "Update add-on", - "description": "Updates an add-on. This action should be used with caution since add-on updates can contain breaking changes. It is highly recommended that you review release notes/change logs before updating an add-on.", - "fields": { - "addon": { - "name": "[%key:component::hassio::services::addon_start::fields::addon::name%]", - "description": "The add-on to update." - } - } - }, "host_reboot": { "name": "Reboot the host system", "description": "Reboots the host system." diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 263cf2dfe13..2515ee04ab3 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -2,10 +2,10 @@ from __future__ import annotations +import re from typing import Any from aiohasupervisor import SupervisorError -from aiohasupervisor.models import OSUpdate from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.components.update import ( @@ -22,7 +22,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ADDONS_COORDINATOR, ATTR_AUTO_UPDATE, - ATTR_CHANGELOG, ATTR_VERSION, ATTR_VERSION_LATEST, DATA_KEY_ADDONS, @@ -36,7 +35,7 @@ from .entity import ( HassioOSEntity, HassioSupervisorEntity, ) -from .update_helper import update_addon, update_core +from .update_helper import update_addon, update_core, update_os ENTITY_DESCRIPTION = UpdateEntityDescription( translation_key="update", @@ -117,11 +116,6 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): """Version installed and in use.""" return self._addon_data[ATTR_VERSION] - @property - def release_summary(self) -> str | None: - """Release summary for the add-on.""" - return self._strip_release_notes() - @property def entity_picture(self) -> str | None: """Return the icon of the add-on if any.""" @@ -131,27 +125,22 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): return f"/api/hassio/addons/{self._addon_slug}/icon" return None - def _strip_release_notes(self) -> str | None: - """Strip the release notes to contain the needed sections.""" - if (notes := self._addon_data[ATTR_CHANGELOG]) is None: - return None - - if ( - f"# {self.latest_version}" in notes - and f"# {self.installed_version}" in notes - ): - # Split the release notes to only what is between the versions if we can - new_notes = notes.split(f"# {self.installed_version}")[0] - if f"# {self.latest_version}" in new_notes: - # Make sure the latest version is still there. - # This can be False if the order of the release notes are not correct - # In that case we just return the whole release notes - return new_notes - return notes - async def async_release_notes(self) -> str | None: """Return the release notes for the update.""" - return self._strip_release_notes() + if ( + changelog := await self.coordinator.get_changelog(self._addon_slug) + ) is None: + return None + + if self.latest_version is None or self.installed_version is None: + return changelog + + regex_pattern = re.compile( + rf"^#* {re.escape(self.latest_version)}\n(?:^(?!#* {re.escape(self.installed_version)}).*\n)*", + re.MULTILINE, + ) + match = regex_pattern.search(changelog) + return match.group(0) if match else changelog async def async_install( self, @@ -163,14 +152,16 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): await update_addon( self.hass, self._addon_slug, backup, self.title, self.installed_version ) - await self.coordinator.force_info_update_supervisor() + await self.coordinator.async_refresh() class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity): """Update entity to handle updates for the Home Assistant Operating System.""" _attr_supported_features = ( - UpdateEntityFeature.INSTALL | UpdateEntityFeature.SPECIFIC_VERSION + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.SPECIFIC_VERSION + | UpdateEntityFeature.BACKUP ) _attr_title = "Home Assistant Operating System" @@ -203,14 +194,7 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity): self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" - try: - await self.coordinator.supervisor_client.os.update( - OSUpdate(version=version) - ) - except SupervisorError as err: - raise HomeAssistantError( - f"Error updating Home Assistant Operating System: {err}" - ) from err + await update_os(self.hass, version, backup) class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity): diff --git a/homeassistant/components/hassio/update_helper.py b/homeassistant/components/hassio/update_helper.py index d801f6b5771..65a3ba38485 100644 --- a/homeassistant/components/hassio/update_helper.py +++ b/homeassistant/components/hassio/update_helper.py @@ -3,7 +3,11 @@ from __future__ import annotations from aiohasupervisor import SupervisorError -from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate +from aiohasupervisor.models import ( + HomeAssistantUpdateOptions, + OSUpdate, + StoreAddonUpdate, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -57,3 +61,24 @@ async def update_core(hass: HomeAssistant, version: str | None, backup: bool) -> ) except SupervisorError as err: raise HomeAssistantError(f"Error updating Home Assistant Core: {err}") from err + + +async def update_os(hass: HomeAssistant, version: str | None, backup: bool) -> None: + """Update OS. + + Optionally make a core backup before updating. + """ + client = get_supervisor_client(hass) + + if backup: + # pylint: disable-next=import-outside-toplevel + from .backup import backup_core_before_update + + await backup_core_before_update(hass) + + try: + await client.os.update(OSUpdate(version=version)) + except SupervisorError as err: + raise HomeAssistantError( + f"Error updating Home Assistant Operating System: {err}" + ) from err diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index c046e20feab..81f7ab9d0da 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -3,7 +3,7 @@ import logging from numbers import Number import re -from typing import Any +from typing import Any, cast import voluptuous as vol @@ -19,6 +19,7 @@ from homeassistant.helpers.dispatcher import ( ) from . import HassioAPIError +from .config import HassioUpdateParametersDict from .const import ( ATTR_DATA, ATTR_ENDPOINT, @@ -29,6 +30,7 @@ from .const import ( ATTR_VERSION, ATTR_WS_EVENT, DATA_COMPONENT, + DATA_CONFIG_STORE, EVENT_SUPERVISOR_EVENT, WS_ID, WS_TYPE, @@ -65,6 +67,8 @@ def async_load_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_subscribe) websocket_api.async_register_command(hass, websocket_update_addon) websocket_api.async_register_command(hass, websocket_update_core) + websocket_api.async_register_command(hass, websocket_update_config_info) + websocket_api.async_register_command(hass, websocket_update_config_update) @callback @@ -182,6 +186,45 @@ async def websocket_update_addon( async def websocket_update_core( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: - """Websocket handler to update an addon.""" + """Websocket handler to update Home Assistant Core.""" await update_core(hass, None, msg["backup"]) connection.send_result(msg[WS_ID]) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "hassio/update/config/info"}) +def websocket_update_config_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Send the stored backup config.""" + connection.send_result( + msg["id"], hass.data[DATA_CONFIG_STORE].data.update_config.to_dict() + ) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "hassio/update/config/update", + vol.Optional("add_on_backup_before_update"): bool, + vol.Optional("add_on_backup_retain_copies"): vol.All(int, vol.Range(min=1)), + vol.Optional("core_backup_before_update"): bool, + } +) +def websocket_update_config_update( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Update the stored backup config.""" + changes = dict(msg) + changes.pop("id") + changes.pop("type") + hass.data[DATA_CONFIG_STORE].update( + update_config=cast(HassioUpdateParametersDict, changes) + ) + connection.send_result(msg["id"]) diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index 789fbc12b8e..d49fc17aa53 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -2,11 +2,15 @@ ATTR_PASSWORD = "password" ATTR_USERNAME = "username" +ATTR_DESTINATION_POSITION = "destination_position" +ATTR_QUEUE_IDS = "queue_ids" DOMAIN = "heos" ENTRY_TITLE = "HEOS System" SERVICE_GET_QUEUE = "get_queue" SERVICE_GROUP_VOLUME_SET = "group_volume_set" SERVICE_GROUP_VOLUME_DOWN = "group_volume_down" SERVICE_GROUP_VOLUME_UP = "group_volume_up" +SERVICE_MOVE_QUEUE_ITEM = "move_queue_item" +SERVICE_REMOVE_FROM_QUEUE = "remove_from_queue" SERVICE_SIGN_IN = "sign_in" SERVICE_SIGN_OUT = "sign_out" diff --git a/homeassistant/components/heos/icons.json b/homeassistant/components/heos/icons.json index c957ac1939c..b03f15a4b0f 100644 --- a/homeassistant/components/heos/icons.json +++ b/homeassistant/components/heos/icons.json @@ -3,6 +3,12 @@ "get_queue": { "service": "mdi:playlist-music" }, + "remove_from_queue": { + "service": "mdi:playlist-remove" + }, + "move_queue_item": { + "service": "mdi:playlist-edit" + }, "group_volume_set": { "service": "mdi:volume-medium" }, diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 6ba672a57ad..810244a815a 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -24,12 +24,10 @@ from pyheos import ( const as heos_const, ) from pyheos.util import mediauri as heos_source -import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, - ATTR_MEDIA_VOLUME_LEVEL, BrowseError, BrowseMedia, MediaClass, @@ -43,30 +41,16 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.media_source import BrowseMediaSource from homeassistant.const import Platform -from homeassistant.core import ( - HomeAssistant, - ServiceResponse, - SupportsResponse, - callback, -) +from homeassistant.core import HomeAssistant, ServiceResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import ( - config_validation as cv, - entity_platform, - entity_registry as er, -) +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow -from .const import ( - DOMAIN as HEOS_DOMAIN, - SERVICE_GET_QUEUE, - SERVICE_GROUP_VOLUME_DOWN, - SERVICE_GROUP_VOLUME_SET, - SERVICE_GROUP_VOLUME_UP, -) +from . import services +from .const import DOMAIN as HEOS_DOMAIN from .coordinator import HeosConfigEntry, HeosCoordinator PARALLEL_UPDATES = 0 @@ -138,25 +122,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add media players for a config entry.""" - # Register custom entity services - platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( - SERVICE_GET_QUEUE, - None, - "async_get_queue", - supports_response=SupportsResponse.ONLY, - ) - platform.async_register_entity_service( - SERVICE_GROUP_VOLUME_SET, - {vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float}, - "async_set_group_volume_level", - ) - platform.async_register_entity_service( - SERVICE_GROUP_VOLUME_DOWN, None, "async_group_volume_down" - ) - platform.async_register_entity_service( - SERVICE_GROUP_VOLUME_UP, None, "async_group_volume_up" - ) + services.register_media_player_services() def add_entities_callback(players: Sequence[HeosPlayer]) -> None: """Add entities for each player.""" @@ -388,6 +354,15 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): await self._player.play_preset_station(index) return + if media_type == "queue": + # media_id must be an int + try: + queue_id = int(media_id) + except ValueError: + raise ValueError(f"Invalid queue id '{media_id}'") from None + await self._player.play_queue(queue_id) + return + raise ValueError(f"Unsupported media type '{media_type}'") @catch_action_error("select source") @@ -501,6 +476,17 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): await self.coordinator.heos.set_group(new_members) return + async def async_remove_from_queue(self, queue_ids: list[int]) -> None: + """Remove items from the queue.""" + await self._player.remove_from_queue(queue_ids) + + @catch_action_error("move queue item") + async def async_move_queue_item( + self, queue_ids: list[int], destination_position: int + ) -> None: + """Move items in the queue.""" + await self._player.move_queue_item(queue_ids, destination_position) + @property def available(self) -> bool: """Return True if the device is available.""" diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index dc11bb7a76d..86c6f6d0533 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -1,19 +1,35 @@ """Services for the HEOS integration.""" +from dataclasses import dataclass import logging +from typing import Final from pyheos import CommandAuthenticationError, Heos, HeosError import voluptuous as vol +from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + issue_registry as ir, +) +from homeassistant.helpers.typing import VolDictType, VolSchemaType from .const import ( + ATTR_DESTINATION_POSITION, ATTR_PASSWORD, + ATTR_QUEUE_IDS, ATTR_USERNAME, DOMAIN, + SERVICE_GET_QUEUE, + SERVICE_GROUP_VOLUME_DOWN, + SERVICE_GROUP_VOLUME_SET, + SERVICE_GROUP_VOLUME_UP, + SERVICE_MOVE_QUEUE_ITEM, + SERVICE_REMOVE_FROM_QUEUE, SERVICE_SIGN_IN, SERVICE_SIGN_OUT, ) @@ -44,6 +60,75 @@ def register(hass: HomeAssistant) -> None: ) +@dataclass(frozen=True) +class EntityServiceDescription: + """Describe an entity service.""" + + name: str + method_name: str + schema: VolDictType | VolSchemaType | None = None + supports_response: SupportsResponse = SupportsResponse.NONE + + def async_register(self, platform: entity_platform.EntityPlatform) -> None: + """Register the service with the platform.""" + platform.async_register_entity_service( + self.name, + self.schema, + self.method_name, + supports_response=self.supports_response, + ) + + +REMOVE_FROM_QUEUE_SCHEMA: Final[VolDictType] = { + vol.Required(ATTR_QUEUE_IDS): vol.All( + cv.ensure_list, + [vol.All(cv.positive_int, vol.Range(min=1))], + vol.Unique(), + ) +} +GROUP_VOLUME_SET_SCHEMA: Final[VolDictType] = { + vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float +} +MOVE_QEUEUE_ITEM_SCHEMA: Final[VolDictType] = { + vol.Required(ATTR_QUEUE_IDS): vol.All( + cv.ensure_list, + [vol.All(vol.Coerce(int), vol.Range(min=1, max=1000))], + vol.Unique(), + ), + vol.Required(ATTR_DESTINATION_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=1, max=1000) + ), +} + +MEDIA_PLAYER_ENTITY_SERVICES: Final = ( + # Player queue services + EntityServiceDescription( + SERVICE_GET_QUEUE, "async_get_queue", supports_response=SupportsResponse.ONLY + ), + EntityServiceDescription( + SERVICE_REMOVE_FROM_QUEUE, "async_remove_from_queue", REMOVE_FROM_QUEUE_SCHEMA + ), + EntityServiceDescription( + SERVICE_MOVE_QUEUE_ITEM, "async_move_queue_item", MOVE_QEUEUE_ITEM_SCHEMA + ), + # Group volume services + EntityServiceDescription( + SERVICE_GROUP_VOLUME_SET, + "async_set_group_volume_level", + GROUP_VOLUME_SET_SCHEMA, + ), + EntityServiceDescription(SERVICE_GROUP_VOLUME_DOWN, "async_group_volume_down"), + EntityServiceDescription(SERVICE_GROUP_VOLUME_UP, "async_group_volume_up"), +) + + +def register_media_player_services() -> None: + """Register media_player entity services.""" + platform = entity_platform.async_get_current_platform() + for service in MEDIA_PLAYER_ENTITY_SERVICES: + service.async_register(platform) + + def _get_controller(hass: HomeAssistant) -> Heos: """Get the HEOS controller instance.""" _LOGGER.warning( diff --git a/homeassistant/components/heos/services.yaml b/homeassistant/components/heos/services.yaml index fa79bd03096..333a15940bc 100644 --- a/homeassistant/components/heos/services.yaml +++ b/homeassistant/components/heos/services.yaml @@ -4,6 +4,39 @@ get_queue: integration: heos domain: media_player +remove_from_queue: + target: + entity: + integration: heos + domain: media_player + fields: + queue_ids: + required: true + selector: + text: + multiple: true + type: number + +move_queue_item: + target: + entity: + integration: heos + domain: media_player + fields: + queue_ids: + required: true + selector: + text: + multiple: true + type: number + destination_position: + required: true + selector: + number: + min: 1 + max: 1000 + step: 1 + group_volume_set: target: entity: diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 38e3349b7c0..c99d73a70d7 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -90,6 +90,30 @@ "name": "Get queue", "description": "Retrieves the queue of the media player." }, + "remove_from_queue": { + "name": "Remove from queue", + "description": "Removes items from the play queue.", + "fields": { + "queue_ids": { + "name": "Queue IDs", + "description": "The IDs (indexes) of the items in the queue to remove." + } + } + }, + "move_queue_item": { + "name": "Move queue item", + "description": "Move one or more items within the play queue.", + "fields": { + "queue_ids": { + "name": "Queue IDs", + "description": "The IDs (indexes) of the items in the queue to move." + }, + "destination_position": { + "name": "Destination position", + "description": "The position index in the queue to move the items to." + } + } + }, "group_volume_down": { "name": "Turn down group volume", "description": "Turns down the group volume." diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index c57e766eaed..3761c935992 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -52,7 +52,7 @@ class HistoryLiveStream: subscriptions: list[CALLBACK_TYPE] end_time_unsub: CALLBACK_TYPE | None = None task: asyncio.Task | None = None - wait_sync_task: asyncio.Task | None = None + wait_sync_future: asyncio.Future[None] | None = None @callback @@ -491,8 +491,8 @@ async def ws_stream( subscriptions.clear() if live_stream.task: live_stream.task.cancel() - if live_stream.wait_sync_task: - live_stream.wait_sync_task.cancel() + if live_stream.wait_sync_future: + live_stream.wait_sync_future.cancel() if live_stream.end_time_unsub: live_stream.end_time_unsub() live_stream.end_time_unsub = None @@ -554,10 +554,12 @@ async def ws_stream( ) ) - live_stream.wait_sync_task = create_eager_task( - get_instance(hass).async_block_till_done() - ) - await live_stream.wait_sync_task + if sync_future := get_instance(hass).async_get_commit_future(): + # Set the future so we can cancel it if the client + # unsubscribes before the commit is done so we don't + # query the database needlessly + live_stream.wait_sync_future = sync_future + await live_stream.wait_sync_future # # Fetch any states from the database that have diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index a69abe26f6c..756a6b3ce9d 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -54,7 +54,7 @@ class HistoryStats: self._period = (MIN_TIME_UTC, MIN_TIME_UTC) self._state: HistoryStatsState = HistoryStatsState(None, None, self._period) self._history_current_period: list[HistoryState] = [] - self._previous_run_before_start = False + self._has_recorder_data = False self._entity_states = set(entity_states) self._duration = duration self._start = start @@ -88,20 +88,20 @@ class HistoryStats: if current_period_start_timestamp > now_timestamp: # History cannot tell the future self._history_current_period = [] - self._previous_run_before_start = True + self._has_recorder_data = False self._state = HistoryStatsState(None, None, self._period) return self._state # # We avoid querying the database if the below did NOT happen: # - # - The previous run happened before the start time - # - The start time changed - # - The period shrank in size + # - No previous run occurred (uninitialized) + # - The start time moved back in time + # - The end time moved back in time # - The previous period ended before now # if ( - not self._previous_run_before_start - and current_period_start_timestamp == previous_period_start_timestamp + self._has_recorder_data + and current_period_start_timestamp >= previous_period_start_timestamp and ( current_period_end_timestamp == previous_period_end_timestamp or ( @@ -110,6 +110,12 @@ class HistoryStats: ) ) ): + start_changed = ( + current_period_start_timestamp != previous_period_start_timestamp + ) + if start_changed: + self._prune_history_cache(current_period_start_timestamp) + new_data = False if event and (new_state := event.data["new_state"]) is not None: if ( @@ -121,7 +127,11 @@ class HistoryStats: HistoryState(new_state.state, new_state.last_changed_timestamp) ) new_data = True - if not new_data and current_period_end_timestamp < now_timestamp: + if ( + not new_data + and current_period_end_timestamp < now_timestamp + and not start_changed + ): # If period has not changed and current time after the period end... # Don't compute anything as the value cannot have changed return self._state @@ -139,7 +149,7 @@ class HistoryStats: HistoryState(new_state.state, new_state.last_changed_timestamp) ) - self._previous_run_before_start = False + self._has_recorder_data = True seconds_matched, match_count = self._async_compute_seconds_and_changes( now_timestamp, @@ -223,3 +233,18 @@ class HistoryStats: # Save value in seconds seconds_matched = elapsed return seconds_matched, match_count + + def _prune_history_cache(self, start_timestamp: float) -> None: + """Remove unnecessary old data from the history state cache from previous runs. + + Update the timestamp of the last record from before the start to the current start time. + """ + trim_count = 0 + for i, history_state in enumerate(self._history_current_period): + if history_state.last_changed >= start_timestamp: + break + history_state.last_changed = start_timestamp + if i > 0: + trim_count += 1 + if trim_count: # Don't slice if no data was removed + self._history_current_period = self._history_current_period[trim_count:] diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index 6323a2eecbf..58ba949d325 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -34,9 +34,9 @@ } }, "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_code": "Failed to sign into Hive. Your two-factor authentication code was incorrect.", + "invalid_username": "Failed to sign in to Hive. Your email address is not recognised.", + "invalid_password": "Failed to sign in to Hive. Incorrect password, please try again.", + "invalid_code": "Failed to sign in to 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%]" }, @@ -105,7 +105,7 @@ "sensor": { "heating": { "state": { - "manual": "Manual", + "manual": "[%key:common::state::manual%]", "off": "[%key:common::state::off%]", "schedule": "Schedule" } diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index fe01a3e9564..01f2acd1851 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -7,13 +7,17 @@ from typing import Any from aiohomeconnect.client import Client as HomeConnectClient import aiohttp +import jwt from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers import ( + config_entry_oauth2_flow, + config_validation as cv, + issue_registry as ir, +) from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries -from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth @@ -86,8 +90,18 @@ async def async_unload_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> bool: """Unload a config entry.""" - async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions") - async_delete_issue(hass, DOMAIN, "deprecated_command_actions") + issue_registry = ir.async_get(hass) + issues_to_delete = [ + "deprecated_set_program_and_option_actions", + "deprecated_command_actions", + ] + [ + issue_id + for (issue_domain, issue_id) in issue_registry.issues + if issue_domain == DOMAIN + and issue_id.startswith("home_connect_too_many_connected_paired_events") + ] + for issue_id in issues_to_delete: + issue_registry.async_delete(DOMAIN, issue_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -97,25 +111,39 @@ async def async_migrate_entry( """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", entry.version) - if entry.version == 1 and entry.minor_version == 1: + if entry.version == 1: + match entry.minor_version: + case 1: - @callback - def update_unique_id( - entity_entry: RegistryEntry, - ) -> dict[str, Any] | None: - """Update unique ID of entity entry.""" - for old_id_suffix, new_id_suffix in OLD_NEW_UNIQUE_ID_SUFFIX_MAP.items(): - if entity_entry.unique_id.endswith(f"-{old_id_suffix}"): - return { - "new_unique_id": entity_entry.unique_id.replace( - old_id_suffix, new_id_suffix - ) - } - return None + @callback + def update_unique_id( + entity_entry: RegistryEntry, + ) -> dict[str, Any] | None: + """Update unique ID of entity entry.""" + for ( + old_id_suffix, + new_id_suffix, + ) in OLD_NEW_UNIQUE_ID_SUFFIX_MAP.items(): + if entity_entry.unique_id.endswith(f"-{old_id_suffix}"): + return { + "new_unique_id": entity_entry.unique_id.replace( + old_id_suffix, new_id_suffix + ) + } + return None - await async_migrate_entries(hass, entry.entry_id, update_unique_id) + await async_migrate_entries(hass, entry.entry_id, update_unique_id) - hass.config_entries.async_update_entry(entry, minor_version=2) + hass.config_entries.async_update_entry(entry, minor_version=2) + case 2: + hass.config_entries.async_update_entry( + entry, + minor_version=3, + unique_id=jwt.decode( + entry.data["token"]["access_token"], + options={"verify_signature": False}, + )["sub"], + ) _LOGGER.debug("Migration to version %s successful", entry.version) return True diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index a28b4ff2b49..7e4523201f9 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -5,37 +5,18 @@ from typing import cast from aiohomeconnect.model import EventKey, StatusKey -from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from .common import setup_home_connect_entry -from .const import ( - BSH_DOOR_STATE_CLOSED, - BSH_DOOR_STATE_LOCKED, - BSH_DOOR_STATE_OPEN, - DOMAIN, - REFRIGERATION_STATUS_DOOR_CLOSED, - REFRIGERATION_STATUS_DOOR_OPEN, -) -from .coordinator import ( - HomeConnectApplianceData, - HomeConnectConfigEntry, - HomeConnectCoordinator, -) +from .const import REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN +from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity PARALLEL_UPDATES = 0 @@ -173,8 +154,6 @@ def _get_entities_for_appliance( for description in BINARY_SENSORS if description.key in appliance.status ) - if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status: - entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance)) return entities @@ -220,83 +199,3 @@ class HomeConnectConnectivityBinarySensor(HomeConnectEntity, BinarySensorEntity) def available(self) -> bool: """Return the availability.""" return self.coordinator.last_update_success - - -class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): - """Binary sensor for Home Connect Generic Door.""" - - _attr_has_entity_name = False - - def __init__( - self, - coordinator: HomeConnectCoordinator, - appliance: HomeConnectApplianceData, - ) -> None: - """Initialize the entity.""" - super().__init__( - coordinator, - appliance, - HomeConnectBinarySensorEntityDescription( - key=StatusKey.BSH_COMMON_DOOR_STATE, - device_class=BinarySensorDeviceClass.DOOR, - boolean_map={ - BSH_DOOR_STATE_CLOSED: False, - BSH_DOOR_STATE_LOCKED: False, - BSH_DOOR_STATE_OPEN: True, - }, - entity_registry_enabled_default=False, - ), - ) - self._attr_unique_id = f"{appliance.info.ha_id}-Door" - self._attr_name = f"{appliance.info.name} Door" - - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - await super().async_added_to_hass() - automations = automations_with_entity(self.hass, self.entity_id) - scripts = scripts_with_entity(self.hass, self.entity_id) - items = automations + scripts - if not items: - return - - entity_reg: er.EntityRegistry = er.async_get(self.hass) - entity_automations = [ - automation_entity - for automation_id in automations - if (automation_entity := entity_reg.async_get(automation_id)) - ] - entity_scripts = [ - script_entity - for script_id in scripts - if (script_entity := entity_reg.async_get(script_id)) - ] - - items_list = [ - f"- [{item.original_name}](/config/automation/edit/{item.unique_id})" - for item in entity_automations - ] + [ - f"- [{item.original_name}](/config/script/edit/{item.unique_id})" - for item in entity_scripts - ] - - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_binary_common_door_sensor_{self.entity_id}", - breaks_in_ha_version="2025.5.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_binary_common_door_sensor", - translation_placeholders={ - "entity": self.entity_id, - "items": "\n".join(items_list), - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Call when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - async_delete_issue( - self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}" - ) diff --git a/homeassistant/components/home_connect/config_flow.py b/homeassistant/components/home_connect/config_flow.py index 02a3ca29335..2b3b2aacf0c 100644 --- a/homeassistant/components/home_connect/config_flow.py +++ b/homeassistant/components/home_connect/config_flow.py @@ -4,6 +4,7 @@ from collections.abc import Mapping import logging from typing import Any +import jwt import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult @@ -19,7 +20,7 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN - MINOR_VERSION = 2 + MINOR_VERSION = 3 @property def logger(self) -> logging.Logger: @@ -45,9 +46,15 @@ class OAuth2FlowHandler( async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Create an oauth config entry or update existing entry for reauth.""" + await self.async_set_unique_id( + jwt.decode( + data["token"]["access_token"], options={"verify_signature": False} + )["sub"] + ) if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="wrong_account") return self.async_update_reload_and_abort( - self._get_reauth_entry(), - data_updates=data, + self._get_reauth_entry(), data_updates=data ) + self._abort_if_unique_id_configured() return await super().async_oauth_create_entry(data) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 0a0e0c78463..9e40de86e24 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -5,6 +5,7 @@ from __future__ import annotations from asyncio import sleep as asyncio_sleep from collections import defaultdict from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass import logging from typing import Any, cast @@ -38,7 +39,7 @@ from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN @@ -46,6 +47,9 @@ from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) +MAX_EXECUTIONS_TIME_WINDOW = 60 * 60 # 1 hour +MAX_EXECUTIONS = 8 + type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] @@ -113,6 +117,7 @@ class HomeConnectCoordinator( ] = {} self.device_registry = dr.async_get(self.hass) self.data = {} + self._execution_tracker: dict[str, list[float]] = defaultdict(list) @cached_property def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: @@ -132,8 +137,11 @@ class HomeConnectCoordinator( self.__dict__.pop("context_listeners", None) def remove_listener_and_invalidate_context_listeners() -> None: - remove_listener() - self.__dict__.pop("context_listeners", None) + # There are cases where the remove_listener will be called + # although it has been already removed somewhere else + with suppress(KeyError): + remove_listener() + self.__dict__.pop("context_listeners", None) return remove_listener_and_invalidate_context_listeners @@ -168,7 +176,7 @@ class HomeConnectCoordinator( f"home_connect-events_listener_task-{self.config_entry.entry_id}", ) - async def _event_listener(self) -> None: + async def _event_listener(self) -> None: # noqa: C901 """Match event with listener for event type.""" retry_time = 10 while True: @@ -234,6 +242,9 @@ class HomeConnectCoordinator( self._call_event_listener(event_message) case EventType.CONNECTED | EventType.PAIRED: + if self.refreshed_too_often_recently(event_message_ha_id): + continue + appliance_info = await self.client.get_specific_appliance( event_message_ha_id ) @@ -586,3 +597,60 @@ class HomeConnectCoordinator( [], ): listener() + + def refreshed_too_often_recently(self, appliance_ha_id: str) -> bool: + """Check if the appliance data hasn't been refreshed too often recently.""" + + now = self.hass.loop.time() + if len(self._execution_tracker[appliance_ha_id]) >= MAX_EXECUTIONS: + return True + + execution_tracker = self._execution_tracker[appliance_ha_id] = [ + timestamp + for timestamp in self._execution_tracker[appliance_ha_id] + if now - timestamp < MAX_EXECUTIONS_TIME_WINDOW + ] + + execution_tracker.append(now) + + if len(execution_tracker) >= MAX_EXECUTIONS: + ir.async_create_issue( + self.hass, + DOMAIN, + f"home_connect_too_many_connected_paired_events_{appliance_ha_id}", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="home_connect_too_many_connected_paired_events", + data={ + "entry_id": self.config_entry.entry_id, + "appliance_ha_id": appliance_ha_id, + }, + translation_placeholders={ + "appliance_name": self.data[appliance_ha_id].info.name, + "times": str(MAX_EXECUTIONS), + "time_window": str(MAX_EXECUTIONS_TIME_WINDOW // 60), + "home_connect_resource_url": "https://www.home-connect.com/global/help-support/error-codes#/Togglebox=15362315-13320636-1/", + "home_assistant_core_new_issue_url": ( + "https://github.com/home-assistant/core/issues/new?template=bug_report.yml" + f"&integration_name={DOMAIN}&integration_link=https://www.home-assistant.io/integrations/{DOMAIN}/" + ), + }, + ) + return True + + return False + + async def reset_execution_tracker(self, appliance_ha_id: str) -> None: + """Reset the execution tracker for a specific appliance.""" + self._execution_tracker.pop(appliance_ha_id, None) + appliance_info = await self.client.get_specific_appliance(appliance_ha_id) + + appliance_data = await self._get_appliance_data( + appliance_info, self.data.get(appliance_info.ha_id) + ) + self.data[appliance_ha_id].update(appliance_data) + for listener, context in self._special_listeners.values(): + if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context: + listener() + self._call_all_event_listeners_for_appliance(appliance_ha_id) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 62892e7c85b..8a608a900be 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.16.3"], - "single_config_entry": true + "requirements": ["aiohomeconnect==0.17.0"], + "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/homeassistant/components/home_connect/repairs.py b/homeassistant/components/home_connect/repairs.py new file mode 100644 index 00000000000..21c6775e549 --- /dev/null +++ b/homeassistant/components/home_connect/repairs.py @@ -0,0 +1,60 @@ +"""Repairs flows for Home Connect.""" + +from typing import cast + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from .coordinator import HomeConnectConfigEntry + + +class EnableApplianceUpdatesFlow(RepairsFlow): + """Handler for enabling appliance's updates after being refreshed too many times.""" + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + assert self.data + entry = self.hass.config_entries.async_get_entry( + cast(str, self.data["entry_id"]) + ) + assert entry + entry = cast(HomeConnectConfigEntry, entry) + await entry.runtime_data.reset_execution_tracker( + cast(str, self.data["appliance_ha_id"]) + ) + return self.async_create_entry(data={}) + + issue_registry = ir.async_get(self.hass) + description_placeholders = None + if issue := issue_registry.async_get_issue(self.handler, self.issue_id): + description_placeholders = issue.translation_placeholders + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders=description_placeholders, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + if issue_id.startswith("home_connect_too_many_connected_paired_events"): + return EnableApplianceUpdatesFlow() + return ConfirmRepairFlow() diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 7d8b315b657..025480828d8 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -11,7 +11,7 @@ from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.program import Execution from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -366,16 +366,37 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): appliance, desc, ) + self.set_options() + + def set_options(self) -> None: + """Set the options for the entity.""" self._attr_options = [ PROGRAMS_TRANSLATION_KEYS_MAP[program.key] - for program in appliance.programs + for program in self.appliance.programs if program.key != ProgramKey.UNKNOWN and ( program.constraints is None - or program.constraints.execution in desc.allowed_executions + or program.constraints.execution + in self.entity_description.allowed_executions ) ] + @callback + def refresh_options(self) -> None: + """Refresh the options for the entity.""" + self.set_options() + self.async_write_ha_state() + + 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.coordinator.async_add_listener( + self.refresh_options, + (self.appliance.info.ha_id, EventKey.BSH_COMMON_APPLIANCE_CONNECTED), + ) + ) + def update_native_value(self) -> None: """Set the program value.""" event = self.appliance.events.get(cast(EventKey, self.bsh_key)) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index f3c73c8a5ff..0f0161971a2 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,7 +1,10 @@ """Provides a sensor for Home Connect.""" +from collections import defaultdict +from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta +from functools import partial import logging from typing import cast @@ -14,7 +17,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfVolume -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util, slugify @@ -42,7 +45,6 @@ class HomeConnectSensorEntityDescription( ): """Entity Description class for sensors.""" - default_value: str | None = None appliance_types: tuple[str, ...] | None = None fetch_unit: bool = False @@ -198,7 +200,6 @@ EVENT_SENSORS = ( key=EventKey.BSH_COMMON_EVENT_PROGRAM_ABORTED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="program_aborted", appliance_types=("Dishwasher", "CleaningRobot", "CookProcessor"), ), @@ -206,7 +207,6 @@ EVENT_SENSORS = ( key=EventKey.BSH_COMMON_EVENT_PROGRAM_FINISHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="program_finished", appliance_types=( "Oven", @@ -222,7 +222,6 @@ EVENT_SENSORS = ( key=EventKey.BSH_COMMON_EVENT_ALARM_CLOCK_ELAPSED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="alarm_clock_elapsed", appliance_types=("Oven", "Cooktop"), ), @@ -230,7 +229,6 @@ EVENT_SENSORS = ( key=EventKey.COOKING_OVEN_EVENT_PREHEAT_FINISHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="preheat_finished", appliance_types=("Oven", "Cooktop"), ), @@ -238,7 +236,6 @@ EVENT_SENSORS = ( key=EventKey.COOKING_OVEN_EVENT_REGULAR_PREHEAT_FINISHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="regular_preheat_finished", appliance_types=("Oven",), ), @@ -246,7 +243,6 @@ EVENT_SENSORS = ( key=EventKey.LAUNDRY_CARE_DRYER_EVENT_DRYING_PROCESS_FINISHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="drying_process_finished", appliance_types=("Dryer",), ), @@ -254,7 +250,6 @@ EVENT_SENSORS = ( key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="salt_nearly_empty", appliance_types=("Dishwasher",), ), @@ -262,7 +257,6 @@ EVENT_SENSORS = ( key=EventKey.DISHCARE_DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="rinse_aid_nearly_empty", appliance_types=("Dishwasher",), ), @@ -270,7 +264,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="bean_container_empty", appliance_types=("CoffeeMaker",), ), @@ -278,7 +271,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="water_tank_empty", appliance_types=("CoffeeMaker",), ), @@ -286,7 +278,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="drip_tray_full", appliance_types=("CoffeeMaker",), ), @@ -294,7 +285,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_KEEP_MILK_TANK_COOL, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="keep_milk_tank_cool", appliance_types=("CoffeeMaker",), ), @@ -302,7 +292,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_20_CUPS, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="descaling_in_20_cups", appliance_types=("CoffeeMaker",), ), @@ -310,7 +299,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_15_CUPS, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="descaling_in_15_cups", appliance_types=("CoffeeMaker",), ), @@ -318,7 +306,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_10_CUPS, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="descaling_in_10_cups", appliance_types=("CoffeeMaker",), ), @@ -326,7 +313,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_5_CUPS, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="descaling_in_5_cups", appliance_types=("CoffeeMaker",), ), @@ -334,7 +320,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_DESCALED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="device_should_be_descaled", appliance_types=("CoffeeMaker",), ), @@ -342,7 +327,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_DESCALING_OVERDUE, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="device_descaling_overdue", appliance_types=("CoffeeMaker",), ), @@ -350,7 +334,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_DESCALING_BLOCKAGE, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="device_descaling_blockage", appliance_types=("CoffeeMaker",), ), @@ -358,7 +341,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_CLEANED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="device_should_be_cleaned", appliance_types=("CoffeeMaker",), ), @@ -366,7 +348,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CLEANING_OVERDUE, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="device_cleaning_overdue", appliance_types=("CoffeeMaker",), ), @@ -374,7 +355,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN20CUPS, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="calc_n_clean_in20cups", appliance_types=("CoffeeMaker",), ), @@ -382,7 +362,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN15CUPS, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="calc_n_clean_in15cups", appliance_types=("CoffeeMaker",), ), @@ -390,7 +369,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN10CUPS, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="calc_n_clean_in10cups", appliance_types=("CoffeeMaker",), ), @@ -398,7 +376,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN5CUPS, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="calc_n_clean_in5cups", appliance_types=("CoffeeMaker",), ), @@ -406,7 +383,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_CALC_N_CLEANED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="device_should_be_calc_n_cleaned", appliance_types=("CoffeeMaker",), ), @@ -414,7 +390,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CALC_N_CLEAN_OVERDUE, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="device_calc_n_clean_overdue", appliance_types=("CoffeeMaker",), ), @@ -422,7 +397,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CALC_N_CLEAN_BLOCKAGE, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="device_calc_n_clean_blockage", appliance_types=("CoffeeMaker",), ), @@ -430,7 +404,6 @@ EVENT_SENSORS = ( key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="freezer_door_alarm", appliance_types=("FridgeFreezer", "Freezer"), ), @@ -438,7 +411,6 @@ EVENT_SENSORS = ( key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="refrigerator_door_alarm", appliance_types=("FridgeFreezer", "Refrigerator"), ), @@ -446,7 +418,6 @@ EVENT_SENSORS = ( key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="freezer_temperature_alarm", appliance_types=("FridgeFreezer", "Freezer"), ), @@ -454,7 +425,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_EMPTY_DUST_BOX_AND_CLEAN_FILTER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="empty_dust_box_and_clean_filter", appliance_types=("CleaningRobot",), ), @@ -462,7 +432,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_ROBOT_IS_STUCK, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="robot_is_stuck", appliance_types=("CleaningRobot",), ), @@ -470,7 +439,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_DOCKING_STATION_NOT_FOUND, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="docking_station_not_found", appliance_types=("CleaningRobot",), ), @@ -478,7 +446,6 @@ EVENT_SENSORS = ( key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_1_FILL_LEVEL_POOR, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="poor_i_dos_1_fill_level", appliance_types=("Washer", "WasherDryer"), ), @@ -486,7 +453,6 @@ EVENT_SENSORS = ( key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_2_FILL_LEVEL_POOR, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="poor_i_dos_2_fill_level", appliance_types=("Washer", "WasherDryer"), ), @@ -494,7 +460,6 @@ EVENT_SENSORS = ( key=EventKey.COOKING_COMMON_EVENT_HOOD_GREASE_FILTER_MAX_SATURATION_NEARLY_REACHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="grease_filter_max_saturation_nearly_reached", appliance_types=("Hood",), ), @@ -502,7 +467,6 @@ EVENT_SENSORS = ( key=EventKey.COOKING_COMMON_EVENT_HOOD_GREASE_FILTER_MAX_SATURATION_REACHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="grease_filter_max_saturation_reached", appliance_types=("Hood",), ), @@ -515,12 +479,6 @@ def _get_entities_for_appliance( ) -> list[HomeConnectEntity]: """Get a list of entities.""" return [ - *[ - HomeConnectEventSensor(entry.runtime_data, appliance, description) - for description in EVENT_SENSORS - if description.appliance_types - and appliance.info.type in description.appliance_types - ], *[ HomeConnectProgramSensor(entry.runtime_data, appliance, desc) for desc in BSH_PROGRAM_SENSORS @@ -534,6 +492,72 @@ def _get_entities_for_appliance( ] +def _add_event_sensor_entity( + entry: HomeConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + appliance: HomeConnectApplianceData, + description: HomeConnectSensorEntityDescription, + remove_event_sensor_listener_list: list[Callable[[], None]], +) -> None: + """Add an event sensor entity.""" + if ( + (appliance_data := entry.runtime_data.data.get(appliance.info.ha_id)) is None + ) or description.key not in appliance_data.events: + return + + for remove_listener in remove_event_sensor_listener_list: + remove_listener() + async_add_entities( + [ + HomeConnectEventSensor(entry.runtime_data, appliance, description), + ] + ) + + +def _add_event_sensor_listeners( + entry: HomeConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + remove_event_sensor_listener_dict: dict[str, list[CALLBACK_TYPE]], +) -> None: + for appliance in entry.runtime_data.data.values(): + if appliance.info.ha_id in remove_event_sensor_listener_dict: + continue + for event_sensor_description in EVENT_SENSORS: + if appliance.info.type not in cast( + tuple[str, ...], event_sensor_description.appliance_types + ): + continue + # We use a list as a kind of lazy initializer, as we can use the + # remove_listener while we are initializing it. + remove_event_sensor_listener_list = remove_event_sensor_listener_dict[ + appliance.info.ha_id + ] + remove_listener = entry.runtime_data.async_add_listener( + partial( + _add_event_sensor_entity, + entry, + async_add_entities, + appliance, + event_sensor_description, + remove_event_sensor_listener_list, + ), + (appliance.info.ha_id, event_sensor_description.key), + ) + remove_event_sensor_listener_list.append(remove_listener) + entry.async_on_unload(remove_listener) + + +def _remove_event_sensor_listeners_on_depaired( + entry: HomeConnectConfigEntry, + remove_event_sensor_listener_dict: dict[str, list[CALLBACK_TYPE]], +) -> None: + registered_listeners_ha_id = set(remove_event_sensor_listener_dict) + actual_appliances = set(entry.runtime_data.data) + for appliance_ha_id in registered_listeners_ha_id - actual_appliances: + for listener in remove_event_sensor_listener_dict.pop(appliance_ha_id): + listener() + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -546,6 +570,32 @@ async def async_setup_entry( async_add_entities, ) + remove_event_sensor_listener_dict: dict[str, list[CALLBACK_TYPE]] = defaultdict( + list + ) + + entry.async_on_unload( + entry.runtime_data.async_add_special_listener( + partial( + _add_event_sensor_listeners, + entry, + async_add_entities, + remove_event_sensor_listener_dict, + ), + (EventKey.BSH_COMMON_APPLIANCE_PAIRED,), + ) + ) + entry.async_on_unload( + entry.runtime_data.async_add_special_listener( + partial( + _remove_event_sensor_listeners_on_depaired, + entry, + remove_event_sensor_listener_dict, + ), + (EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,), + ) + ) + class HomeConnectSensor(HomeConnectEntity, SensorEntity): """Sensor class for Home Connect.""" @@ -650,8 +700,5 @@ class HomeConnectEventSensor(HomeConnectSensor): def update_native_value(self) -> None: """Update the sensor's status.""" - event = self.appliance.events.get(cast(EventKey, self.bsh_key)) - if event: - self._update_native_value(event.value) - elif not self._attr_native_value: - self._attr_native_value = self.entity_description.default_value + event = self.appliance.events[cast(EventKey, self.bsh_key)] + self._update_native_value(event.value) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 5072a4d49a7..19d7cc06046 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -14,13 +14,15 @@ } }, "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_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%]" + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "wrong_account": "Please ensure you reconfigure against the same account." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -110,6 +112,17 @@ } }, "issues": { + "home_connect_too_many_connected_paired_events": { + "title": "{appliance_name} sent too many connected or paired events", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::home_connect_too_many_connected_paired_events::title%]", + "description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please create an issue in the [Home Assistant core repository]({home_assistant_core_new_issue_url})." + } + } + } + }, "deprecated_time_alarm_clock_in_automations_scripts": { "title": "Deprecated alarm clock entity detected in some automations or scripts", "fix_flow": { @@ -132,17 +145,6 @@ } } }, - "deprecated_binary_common_door_sensor": { - "title": "Deprecated binary door sensor detected in some automations or scripts", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::home_connect::issues::deprecated_binary_common_door_sensor::title%]", - "description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue." - } - } - } - }, "deprecated_command_actions": { "title": "The command related actions are deprecated in favor of the new buttons", "fix_flow": { @@ -487,9 +489,9 @@ }, "warming_level": { "options": { - "cooking_oven_enum_type_warming_level_low": "Low", - "cooking_oven_enum_type_warming_level_medium": "Medium", - "cooking_oven_enum_type_warming_level_high": "High" + "cooking_oven_enum_type_warming_level_low": "[%key:common::state::low%]", + "cooking_oven_enum_type_warming_level_medium": "[%key:common::state::medium%]", + "cooking_oven_enum_type_warming_level_high": "[%key:common::state::high%]" } }, "washer_temperature": { @@ -511,7 +513,7 @@ }, "spin_speed": { "options": { - "laundry_care_washer_enum_type_spin_speed_off": "Off", + "laundry_care_washer_enum_type_spin_speed_off": "[%key:common::state::off%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_400": "400 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m_600": "600 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m_700": "700 rpm", @@ -521,15 +523,15 @@ "laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "1200 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "1400 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "1600 rpm", - "laundry_care_washer_enum_type_spin_speed_ul_off": "Off", - "laundry_care_washer_enum_type_spin_speed_ul_low": "Low", - "laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium", - "laundry_care_washer_enum_type_spin_speed_ul_high": "High" + "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]", + "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:common::state::low%]", + "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:common::state::medium%]", + "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:common::state::high%]" } }, "vario_perfect": { "options": { - "laundry_care_common_enum_type_vario_perfect_off": "Off", + "laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]", "laundry_care_common_enum_type_vario_perfect_eco_perfect": "Eco perfect", "laundry_care_common_enum_type_vario_perfect_speed_perfect": "Speed perfect" } @@ -1468,9 +1470,9 @@ "warming_level": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_warming_level::name%]", "state": { - "cooking_oven_enum_type_warming_level_low": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_low%]", - "cooking_oven_enum_type_warming_level_medium": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_medium%]", - "cooking_oven_enum_type_warming_level_high": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_high%]" + "cooking_oven_enum_type_warming_level_low": "[%key:common::state::low%]", + "cooking_oven_enum_type_warming_level_medium": "[%key:common::state::medium%]", + "cooking_oven_enum_type_warming_level_high": "[%key:common::state::high%]" } }, "washer_temperature": { @@ -1494,7 +1496,7 @@ "spin_speed": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]", "state": { - "laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]", + "laundry_care_washer_enum_type_spin_speed_off": "[%key:common::state::off%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_400%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_600%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_700%]", @@ -1504,16 +1506,16 @@ "laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1200%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1400%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1600%]", - "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_off%]", - "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]", - "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]", - "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_high%]" + "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]", + "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:common::state::low%]", + "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:common::state::medium%]", + "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:common::state::high%]" } }, "vario_perfect": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]", "state": { - "laundry_care_common_enum_type_vario_perfect_off": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_off%]", + "laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]", "laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]", "laundry_care_common_enum_type_vario_perfect_speed_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_speed_perfect%]" } @@ -1536,7 +1538,7 @@ "pause": "[%key:common::state::paused%]", "actionrequired": "Action required", "finished": "Finished", - "error": "Error", + "error": "[%key:common::state::error%]", "aborting": "Aborting" } }, @@ -1549,31 +1551,39 @@ } }, "coffee_counter": { - "name": "Coffees" + "name": "Coffees", + "unit_of_measurement": "coffees" }, "powder_coffee_counter": { - "name": "Powder coffees" + "name": "Powder coffees", + "unit_of_measurement": "[%key:component::home_connect::entity::sensor::coffee_counter::unit_of_measurement%]" }, "hot_water_counter": { "name": "Hot water" }, "hot_water_cups_counter": { - "name": "Hot water cups" + "name": "Hot water cups", + "unit_of_measurement": "cups" }, "hot_milk_counter": { - "name": "Hot milk cups" + "name": "Hot milk cups", + "unit_of_measurement": "[%key:component::home_connect::entity::sensor::hot_water_cups_counter::unit_of_measurement%]" }, "frothy_milk_counter": { - "name": "Frothy milk cups" + "name": "Frothy milk cups", + "unit_of_measurement": "[%key:component::home_connect::entity::sensor::hot_water_cups_counter::unit_of_measurement%]" }, "milk_counter": { - "name": "Milk cups" + "name": "Milk cups", + "unit_of_measurement": "[%key:component::home_connect::entity::sensor::hot_water_cups_counter::unit_of_measurement%]" }, "coffee_and_milk_counter": { - "name": "Coffee and milk cups" + "name": "Coffee and milk cups", + "unit_of_measurement": "[%key:component::home_connect::entity::sensor::hot_water_cups_counter::unit_of_measurement%]" }, "ristretto_espresso_counter": { - "name": "Ristretto espresso cups" + "name": "Ristretto espresso cups", + "unit_of_measurement": "[%key:component::home_connect::entity::sensor::hot_water_cups_counter::unit_of_measurement%]" }, "battery_level": { "name": "Battery level" @@ -1587,7 +1597,7 @@ "streaminglocal": "Streaming local", "streamingcloud": "Streaming cloud", "streaminglocal_and_cloud": "Streaming local and cloud", - "error": "Error" + "error": "[%key:common::state::error%]" } }, "last_selected_map": { diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 897b7d50e31..372f4fa9955 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -61,7 +61,7 @@ reload_config_entry: required: false example: 8955375327824e14ba89e4b29cc3ec9a selector: - text: + config_entry: save_persistent_states: diff --git a/homeassistant/components/homeassistant/triggers/time_pattern.py b/homeassistant/components/homeassistant/triggers/time_pattern.py index df49a79bcb6..14096d87277 100644 --- a/homeassistant/components/homeassistant/triggers/time_pattern.py +++ b/homeassistant/components/homeassistant/triggers/time_pattern.py @@ -37,6 +37,8 @@ class TimePattern: if isinstance(value, str) and value.startswith("/"): number = int(value[1:]) + if number == 0: + raise vol.Invalid(f"must be a value between 1 and {self.maximum}") else: value = number = int(value) diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 9fd88ee40aa..fbd34743496 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -17,6 +17,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.COVER, Platform.LIGHT, Platform.LOCK, diff --git a/homeassistant/components/homee/climate.py b/homeassistant/components/homee/climate.py new file mode 100644 index 00000000000..3411d31461c --- /dev/null +++ b/homeassistant/components/homee/climate.py @@ -0,0 +1,200 @@ +"""The Homee climate platform.""" + +from typing import Any + +from pyHomee.const import AttributeType, NodeProfile +from pyHomee.model import HomeeNode + +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, + PRESET_BOOST, + PRESET_ECO, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .const import CLIMATE_PROFILES, DOMAIN, HOMEE_UNIT_TO_HA_UNIT, PRESET_MANUAL +from .entity import HomeeNodeEntity + +PARALLEL_UPDATES = 0 + +ROOM_THERMOSTATS = { + NodeProfile.ROOM_THERMOSTAT, + NodeProfile.ROOM_THERMOSTAT_WITH_HUMIDITY_SENSOR, + NodeProfile.WIFI_ROOM_THERMOSTAT, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the climate component.""" + + async_add_devices( + HomeeClimate(node, config_entry) + for node in config_entry.runtime_data.nodes + if node.profile in CLIMATE_PROFILES + ) + + +class HomeeClimate(HomeeNodeEntity, ClimateEntity): + """Representation of a Homee climate entity.""" + + _attr_name = None + _attr_translation_key = DOMAIN + + def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: + """Initialize a Homee climate entity.""" + super().__init__(node, entry) + + ( + self._attr_supported_features, + self._attr_hvac_modes, + self._attr_preset_modes, + ) = get_climate_features(self._node) + + self._target_temp = self._node.get_attribute_by_type( + AttributeType.TARGET_TEMPERATURE + ) + assert self._target_temp is not None + self._attr_temperature_unit = str(HOMEE_UNIT_TO_HA_UNIT[self._target_temp.unit]) + self._attr_target_temperature_step = self._target_temp.step_value + self._attr_unique_id = f"{self._attr_unique_id}-{self._target_temp.id}" + + self._heating_mode = self._node.get_attribute_by_type( + AttributeType.HEATING_MODE + ) + self._temperature = self._node.get_attribute_by_type(AttributeType.TEMPERATURE) + self._valve_position = self._node.get_attribute_by_type( + AttributeType.CURRENT_VALVE_POSITION + ) + + @property + def hvac_mode(self) -> HVACMode: + """Return the hvac operation mode.""" + if ClimateEntityFeature.TURN_OFF in self.supported_features and ( + self._heating_mode is not None + ): + if self._heating_mode.current_value == 0: + return HVACMode.OFF + + return HVACMode.HEAT + + @property + def hvac_action(self) -> HVACAction: + """Return the hvac action.""" + if self._heating_mode is not None and self._heating_mode.current_value == 0: + return HVACAction.OFF + + if ( + self._valve_position is not None and self._valve_position.current_value == 0 + ) or ( + self._temperature is not None + and self._temperature.current_value >= self.target_temperature + ): + return HVACAction.IDLE + + return HVACAction.HEATING + + @property + def preset_mode(self) -> str: + """Return the present preset mode.""" + if ( + ClimateEntityFeature.PRESET_MODE in self.supported_features + and self._heating_mode is not None + and self._heating_mode.current_value > 0 + ): + assert self._attr_preset_modes is not None + return self._attr_preset_modes[int(self._heating_mode.current_value) - 1] + + return PRESET_NONE + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if self._temperature is not None: + return self._temperature.current_value + return None + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + assert self._target_temp is not None + return self._target_temp.current_value + + @property + def min_temp(self) -> float: + """Return the lowest settable target temperature.""" + assert self._target_temp is not None + return self._target_temp.minimum + + @property + def max_temp(self) -> float: + """Return the lowest settable target temperature.""" + assert self._target_temp is not None + return self._target_temp.maximum + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + # Currently only HEAT and OFF are supported. + assert self._heating_mode is not None + await self.async_set_homee_value( + self._heating_mode, float(hvac_mode == HVACMode.HEAT) + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + assert self._heating_mode is not None and self._attr_preset_modes is not None + await self.async_set_homee_value( + self._heating_mode, self._attr_preset_modes.index(preset_mode) + 1 + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + assert self._target_temp is not None + if ATTR_TEMPERATURE in kwargs: + await self.async_set_homee_value( + self._target_temp, kwargs[ATTR_TEMPERATURE] + ) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + assert self._heating_mode is not None + await self.async_set_homee_value(self._heating_mode, 1) + + async def async_turn_off(self) -> None: + """Turn the entity on.""" + assert self._heating_mode is not None + await self.async_set_homee_value(self._heating_mode, 0) + + +def get_climate_features( + node: HomeeNode, +) -> tuple[ClimateEntityFeature, list[HVACMode], list[str] | None]: + """Determine supported climate features of a node based on the available attributes.""" + features = ClimateEntityFeature.TARGET_TEMPERATURE + hvac_modes = [HVACMode.HEAT] + preset_modes: list[str] = [] + + if ( + attribute := node.get_attribute_by_type(AttributeType.HEATING_MODE) + ) is not None: + features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + hvac_modes.append(HVACMode.OFF) + + if attribute.maximum > 1: + # Node supports more modes than off and heating. + features |= ClimateEntityFeature.PRESET_MODE + preset_modes.extend([PRESET_ECO, PRESET_BOOST, PRESET_MANUAL]) + + if len(preset_modes) > 0: + preset_modes.insert(0, PRESET_NONE) + return (features, hvac_modes, preset_modes if len(preset_modes) > 0 else None) diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index 2c614d3f5eb..468fb2d49ac 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -95,3 +95,6 @@ LIGHT_PROFILES = [ NodeProfile.WIFI_DIMMABLE_LIGHT, NodeProfile.WIFI_ON_OFF_DIMMABLE_METERING_SWITCH, ] + +# Climate Presets +PRESET_MANUAL = "manual" diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json index b4ad8871568..d6d327a32c5 100644 --- a/homeassistant/components/homee/icons.json +++ b/homeassistant/components/homee/icons.json @@ -1,5 +1,16 @@ { "entity": { + "climate": { + "homee": { + "state_attributes": { + "preset_mode": { + "state": { + "manual": "mdi:hand-back-left" + } + } + } + } + }, "sensor": { "brightness": { "default": "mdi:brightness-5" diff --git a/homeassistant/components/homee/number.py b/homeassistant/components/homee/number.py index 5f76b826fcf..231c2ecac94 100644 --- a/homeassistant/components/homee/number.py +++ b/homeassistant/components/homee/number.py @@ -1,5 +1,8 @@ """The Homee number platform.""" +from collections.abc import Callable +from dataclasses import dataclass + from pyHomee.const import AttributeType from pyHomee.model import HomeeAttribute @@ -8,7 +11,7 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, UnitOfSpeed from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -18,69 +21,89 @@ from .entity import HomeeEntity PARALLEL_UPDATES = 0 + +@dataclass(frozen=True, kw_only=True) +class HomeeNumberEntityDescription(NumberEntityDescription): + """A class that describes Homee number entities.""" + + native_value_fn: Callable[[float], float] = lambda value: value + set_native_value_fn: Callable[[float], float] = lambda value: value + + NUMBER_DESCRIPTIONS = { - AttributeType.DOWN_POSITION: NumberEntityDescription( + AttributeType.DOWN_POSITION: HomeeNumberEntityDescription( key="down_position", entity_category=EntityCategory.CONFIG, ), - AttributeType.DOWN_SLAT_POSITION: NumberEntityDescription( + AttributeType.DOWN_SLAT_POSITION: HomeeNumberEntityDescription( key="down_slat_position", entity_category=EntityCategory.CONFIG, ), - AttributeType.DOWN_TIME: NumberEntityDescription( + AttributeType.DOWN_TIME: HomeeNumberEntityDescription( key="down_time", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - AttributeType.ENDPOSITION_CONFIGURATION: NumberEntityDescription( + AttributeType.ENDPOSITION_CONFIGURATION: HomeeNumberEntityDescription( key="endposition_configuration", entity_category=EntityCategory.CONFIG, ), - AttributeType.MOTION_ALARM_CANCELATION_DELAY: NumberEntityDescription( + AttributeType.MOTION_ALARM_CANCELATION_DELAY: HomeeNumberEntityDescription( key="motion_alarm_cancelation_delay", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY: NumberEntityDescription( + AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY: HomeeNumberEntityDescription( key="open_window_detection_sensibility", entity_category=EntityCategory.CONFIG, ), - AttributeType.POLLING_INTERVAL: NumberEntityDescription( + AttributeType.POLLING_INTERVAL: HomeeNumberEntityDescription( key="polling_interval", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - AttributeType.SHUTTER_SLAT_TIME: NumberEntityDescription( + AttributeType.SHUTTER_SLAT_TIME: HomeeNumberEntityDescription( key="shutter_slat_time", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - AttributeType.SLAT_MAX_ANGLE: NumberEntityDescription( + AttributeType.SLAT_MAX_ANGLE: HomeeNumberEntityDescription( key="slat_max_angle", entity_category=EntityCategory.CONFIG, ), - AttributeType.SLAT_MIN_ANGLE: NumberEntityDescription( + AttributeType.SLAT_MIN_ANGLE: HomeeNumberEntityDescription( key="slat_min_angle", entity_category=EntityCategory.CONFIG, ), - AttributeType.SLAT_STEPS: NumberEntityDescription( + AttributeType.SLAT_STEPS: HomeeNumberEntityDescription( key="slat_steps", entity_category=EntityCategory.CONFIG, ), - AttributeType.TEMPERATURE_OFFSET: NumberEntityDescription( + AttributeType.TEMPERATURE_OFFSET: HomeeNumberEntityDescription( key="temperature_offset", entity_category=EntityCategory.CONFIG, ), - AttributeType.UP_TIME: NumberEntityDescription( + AttributeType.UP_TIME: HomeeNumberEntityDescription( key="up_time", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - AttributeType.WAKE_UP_INTERVAL: NumberEntityDescription( + AttributeType.WAKE_UP_INTERVAL: HomeeNumberEntityDescription( key="wake_up_interval", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), + AttributeType.WIND_MONITORING_STATE: HomeeNumberEntityDescription( + key="wind_monitoring_state", + device_class=NumberDeviceClass.WIND_SPEED, + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=22.5, + native_step=2.5, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + native_value_fn=lambda value: value * 2.5, + set_native_value_fn=lambda value: value / 2.5, + ), } @@ -102,20 +125,25 @@ async def async_setup_entry( class HomeeNumber(HomeeEntity, NumberEntity): """Representation of a Homee number.""" + entity_description: HomeeNumberEntityDescription + def __init__( self, attribute: HomeeAttribute, entry: HomeeConfigEntry, - description: NumberEntityDescription, + description: HomeeNumberEntityDescription, ) -> None: """Initialize a Homee number entity.""" super().__init__(attribute, entry) self.entity_description = description self._attr_translation_key = description.key - self._attr_native_unit_of_measurement = HOMEE_UNIT_TO_HA_UNIT[attribute.unit] - self._attr_native_min_value = attribute.minimum - self._attr_native_max_value = attribute.maximum - self._attr_native_step = attribute.step_value + self._attr_native_unit_of_measurement = ( + description.native_unit_of_measurement + or HOMEE_UNIT_TO_HA_UNIT[attribute.unit] + ) + self._attr_native_min_value = description.native_min_value or attribute.minimum + self._attr_native_max_value = description.native_max_value or attribute.maximum + self._attr_native_step = description.native_step or attribute.step_value @property def available(self) -> bool: @@ -123,10 +151,12 @@ class HomeeNumber(HomeeEntity, NumberEntity): return super().available and self._attribute.editable @property - def native_value(self) -> int: + def native_value(self) -> float | None: """Return the native value of the number.""" - return int(self._attribute.current_value) + return self.entity_description.native_value_fn(self._attribute.current_value) async def async_set_native_value(self, value: float) -> None: """Set the selected value.""" - await self.async_set_homee_value(value) + await self.async_set_homee_value( + self.entity_description.set_native_value_fn(value) + ) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index da8357d16bc..f8d83a3073e 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "Homee {name} ({host})", + "flow_title": "homee {name} ({host})", "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }, @@ -18,9 +18,9 @@ "username": "[%key:common::config_flow::data::username%]" }, "data_description": { - "host": "The IP address of your Homee.", - "username": "The username for your Homee.", - "password": "The password for your Homee." + "host": "The IP address of your homee.", + "username": "The username for your homee.", + "password": "The password for your homee." } } } @@ -45,7 +45,7 @@ "load_alarm": { "name": "Load", "state": { - "off": "Normal", + "off": "[%key:common::state::normal%]", "on": "Overload" } }, @@ -131,6 +131,17 @@ "name": "Ventilate" } }, + "climate": { + "homee": { + "state_attributes": { + "preset_mode": { + "state": { + "manual": "[%key:common::state::manual%]" + } + } + } + } + }, "light": { "light_instance": { "name": "Light {instance}" @@ -178,6 +189,9 @@ }, "wake_up_interval": { "name": "Wake-up interval" + }, + "wind_monitoring_state": { + "name": "Threshold for wind trigger" } }, "select": { @@ -297,8 +311,8 @@ "open": "[%key:common::state::open%]", "closed": "[%key:common::state::closed%]", "partial": "Partially open", - "opening": "Opening", - "closing": "Closing" + "opening": "[%key:common::state::opening%]", + "closing": "[%key:common::state::closing%]" } }, "uv": { @@ -341,7 +355,7 @@ }, "exceptions": { "connection_closed": { - "message": "Could not connect to Homee while setting attribute." + "message": "Could not connect to homee while setting attribute." } } } diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 9bd5711832c..8b526b62302 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -31,6 +31,7 @@ from homeassistant.components.device_automation.trigger import ( async_validate_trigger_config, ) from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventDeviceClass +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN @@ -49,6 +50,7 @@ from homeassistant.const import ( CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, + CONF_TYPE, EVENT_HOMEASSISTANT_STOP, SERVICE_RELOAD, ) @@ -83,6 +85,7 @@ from homeassistant.loader import IntegrationNotFound, async_get_integration from homeassistant.util.async_ import create_eager_task from . import ( # noqa: F401 + type_air_purifiers, type_cameras, type_covers, type_fans, @@ -113,6 +116,8 @@ from .const import ( CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_HUMIDITY_SENSOR, CONF_LINKED_MOTION_SENSOR, + CONF_LINKED_PM25_SENSOR, + CONF_LINKED_TEMPERATURE_SENSOR, CONFIG_OPTIONS, DEFAULT_EXCLUDE_ACCESSORY_MODE, DEFAULT_HOMEKIT_MODE, @@ -126,6 +131,7 @@ from .const import ( SERVICE_HOMEKIT_UNPAIR, SHUTDOWN_TIMEOUT, SIGNAL_RELOAD_ENTITIES, + TYPE_AIR_PURIFIER, ) from .iidmanager import AccessoryIIDStorage from .models import HomeKitConfigEntry, HomeKitEntryData @@ -169,6 +175,8 @@ MOTION_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.MOTION) MOTION_SENSOR = (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.MOTION) DOORBELL_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.DOORBELL) HUMIDITY_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.HUMIDITY) +TEMPERATURE_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.TEMPERATURE) +PM25_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.PM25) def _has_all_unique_names_and_ports( @@ -1136,6 +1144,21 @@ class HomeKit: CONF_LINKED_DOORBELL_SENSOR, doorbell_event_entity_id ) + if domain == FAN_DOMAIN: + if current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR): + config[entity_id].setdefault( + CONF_LINKED_HUMIDITY_SENSOR, current_humidity_sensor_entity_id + ) + if current_pm25_sensor_entity_id := lookup.get(PM25_SENSOR): + config[entity_id].setdefault(CONF_TYPE, TYPE_AIR_PURIFIER) + config[entity_id].setdefault( + CONF_LINKED_PM25_SENSOR, current_pm25_sensor_entity_id + ) + if current_temperature_sensor_entity_id := lookup.get(TEMPERATURE_SENSOR): + config[entity_id].setdefault( + CONF_LINKED_TEMPERATURE_SENSOR, current_temperature_sensor_entity_id + ) + if domain == HUMIDIFIER_DOMAIN and ( current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR) ): diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 0d810d6986d..95842d56094 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -85,6 +85,8 @@ from .const import ( SERV_ACCESSORY_INFO, SERV_BATTERY_SERVICE, SIGNAL_RELOAD_ENTITIES, + TYPE_AIR_PURIFIER, + TYPE_FAN, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -112,6 +114,10 @@ SWITCH_TYPES = { TYPE_SWITCH: "Switch", TYPE_VALVE: "ValveSwitch", } +FAN_TYPES = { + TYPE_AIR_PURIFIER: "AirPurifier", + TYPE_FAN: "Fan", +} TYPES: Registry[str, type[HomeAccessory]] = Registry() RELOAD_ON_CHANGE_ATTRS = ( @@ -178,7 +184,10 @@ def get_accessory( # noqa: C901 a_type = "WindowCovering" elif state.domain == "fan": - a_type = "Fan" + if fan_type := config.get(CONF_TYPE): + a_type = FAN_TYPES[fan_type] + else: + a_type = "Fan" elif state.domain == "humidifier": a_type = "HumidifierDehumidifier" @@ -236,6 +245,13 @@ def get_accessory( # noqa: C901 a_type = "CarbonDioxideSensor" elif device_class == SensorDeviceClass.ILLUMINANCE or unit == LIGHT_LUX: a_type = "LightSensor" + else: + _LOGGER.debug( + "%s: Unsupported sensor type (device_class=%s) (unit=%s)", + state.entity_id, + device_class, + unit, + ) elif state.domain == "switch": if switch_type := config.get(CONF_TYPE): diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 00b3de49169..ae682a0ea2d 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -49,9 +49,13 @@ CONF_EXCLUDE_ACCESSORY_MODE = "exclude_accessory_mode" CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor" CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor" CONF_LINKED_DOORBELL_SENSOR = "linked_doorbell_sensor" +CONF_LINKED_FILTER_CHANGE_INDICATION = "linked_filter_change_indication_binary_sensor" +CONF_LINKED_FILTER_LIFE_LEVEL = "linked_filter_life_level_sensor" CONF_LINKED_MOTION_SENSOR = "linked_motion_sensor" CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor" CONF_LINKED_OBSTRUCTION_SENSOR = "linked_obstruction_sensor" +CONF_LINKED_PM25_SENSOR = "linked_pm25_sensor" +CONF_LINKED_TEMPERATURE_SENSOR = "linked_temperature_sensor" CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold" CONF_MAX_FPS = "max_fps" CONF_MAX_HEIGHT = "max_height" @@ -120,12 +124,15 @@ TYPE_SHOWER = "shower" TYPE_SPRINKLER = "sprinkler" TYPE_SWITCH = "switch" TYPE_VALVE = "valve" +TYPE_FAN = "fan" +TYPE_AIR_PURIFIER = "air_purifier" # #### Categories #### CATEGORY_RECEIVER = 34 # #### Services #### SERV_ACCESSORY_INFO = "AccessoryInformation" +SERV_AIR_PURIFIER = "AirPurifier" SERV_AIR_QUALITY_SENSOR = "AirQualitySensor" SERV_BATTERY_SERVICE = "BatteryService" SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement" @@ -135,6 +142,7 @@ SERV_CONTACT_SENSOR = "ContactSensor" SERV_DOOR = "Door" SERV_DOORBELL = "Doorbell" SERV_FANV2 = "Fanv2" +SERV_FILTER_MAINTENANCE = "FilterMaintenance" SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener" SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier" SERV_HUMIDITY_SENSOR = "HumiditySensor" @@ -181,6 +189,7 @@ CHAR_CONFIGURED_NAME = "ConfiguredName" CHAR_CONTACT_SENSOR_STATE = "ContactSensorState" CHAR_COOLING_THRESHOLD_TEMPERATURE = "CoolingThresholdTemperature" CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = "CurrentAmbientLightLevel" +CHAR_CURRENT_AIR_PURIFIER_STATE = "CurrentAirPurifierState" CHAR_CURRENT_DOOR_STATE = "CurrentDoorState" CHAR_CURRENT_FAN_STATE = "CurrentFanState" CHAR_CURRENT_HEATING_COOLING = "CurrentHeatingCoolingState" @@ -192,6 +201,8 @@ CHAR_CURRENT_TEMPERATURE = "CurrentTemperature" CHAR_CURRENT_TILT_ANGLE = "CurrentHorizontalTiltAngle" CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState" CHAR_DEHUMIDIFIER_THRESHOLD_HUMIDITY = "RelativeHumidityDehumidifierThreshold" +CHAR_FILTER_CHANGE_INDICATION = "FilterChangeIndication" +CHAR_FILTER_LIFE_LEVEL = "FilterLifeLevel" CHAR_FIRMWARE_REVISION = "FirmwareRevision" CHAR_HARDWARE_REVISION = "HardwareRevision" CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature" @@ -229,6 +240,7 @@ CHAR_SMOKE_DETECTED = "SmokeDetected" CHAR_STATUS_LOW_BATTERY = "StatusLowBattery" CHAR_STREAMING_STRATUS = "StreamingStatus" CHAR_SWING_MODE = "SwingMode" +CHAR_TARGET_AIR_PURIFIER_STATE = "TargetAirPurifierState" CHAR_TARGET_DOOR_STATE = "TargetDoorState" CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState" CHAR_TARGET_POSITION = "TargetPosition" @@ -256,6 +268,7 @@ PROP_VALID_VALUES = "ValidValues" # #### Thresholds #### THRESHOLD_CO = 25 THRESHOLD_CO2 = 1000 +THRESHOLD_FILTER_CHANGE_NEEDED = 10 # #### Default values #### DEFAULT_MIN_TEMP_WATER_HEATER = 40 # °C diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 4ae2e43dfb2..431de804023 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.4.0", + "fnv-hash-fast==1.5.0", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/homekit/type_air_purifiers.py b/homeassistant/components/homekit/type_air_purifiers.py new file mode 100644 index 00000000000..25d305a0aa9 --- /dev/null +++ b/homeassistant/components/homekit/type_air_purifiers.py @@ -0,0 +1,469 @@ +"""Class to hold all air purifier accessories.""" + +import logging +from typing import Any + +from pyhap.characteristic import Characteristic +from pyhap.const import CATEGORY_AIR_PURIFIER +from pyhap.service import Service +from pyhap.util import callback as pyhap_callback + +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import ( + Event, + EventStateChangedData, + HassJobType, + State, + callback, +) +from homeassistant.helpers.event import async_track_state_change_event + +from .accessories import TYPES +from .const import ( + CHAR_ACTIVE, + CHAR_AIR_QUALITY, + CHAR_CURRENT_AIR_PURIFIER_STATE, + CHAR_CURRENT_HUMIDITY, + CHAR_CURRENT_TEMPERATURE, + CHAR_FILTER_CHANGE_INDICATION, + CHAR_FILTER_LIFE_LEVEL, + CHAR_NAME, + CHAR_PM25_DENSITY, + CHAR_TARGET_AIR_PURIFIER_STATE, + CONF_LINKED_FILTER_CHANGE_INDICATION, + CONF_LINKED_FILTER_LIFE_LEVEL, + CONF_LINKED_HUMIDITY_SENSOR, + CONF_LINKED_PM25_SENSOR, + CONF_LINKED_TEMPERATURE_SENSOR, + SERV_AIR_PURIFIER, + SERV_AIR_QUALITY_SENSOR, + SERV_FILTER_MAINTENANCE, + SERV_HUMIDITY_SENSOR, + SERV_TEMPERATURE_SENSOR, + THRESHOLD_FILTER_CHANGE_NEEDED, +) +from .type_fans import ATTR_PRESET_MODE, CHAR_ROTATION_SPEED, Fan +from .util import cleanup_name_for_homekit, convert_to_float, density_to_air_quality + +_LOGGER = logging.getLogger(__name__) + +CURRENT_STATE_INACTIVE = 0 +CURRENT_STATE_IDLE = 1 +CURRENT_STATE_PURIFYING_AIR = 2 +TARGET_STATE_MANUAL = 0 +TARGET_STATE_AUTO = 1 +FILTER_CHANGE_FILTER = 1 +FILTER_OK = 0 + +IGNORED_STATES = {STATE_UNAVAILABLE, STATE_UNKNOWN} + + +@TYPES.register("AirPurifier") +class AirPurifier(Fan): + """Generate an AirPurifier accessory for an air purifier entity. + + Currently supports, in addition to Fan properties: + temperature; humidity; PM2.5; auto mode. + """ + + def __init__(self, *args: Any) -> None: + """Initialize a new AirPurifier accessory object.""" + super().__init__(*args, category=CATEGORY_AIR_PURIFIER) + + self.auto_preset: str | None = None + if self.preset_modes is not None: + for preset in self.preset_modes: + if str(preset).lower() == "auto": + self.auto_preset = preset + break + + def create_services(self) -> Service: + """Create and configure the primary service for this accessory.""" + self.chars.append(CHAR_ACTIVE) + self.chars.append(CHAR_CURRENT_AIR_PURIFIER_STATE) + self.chars.append(CHAR_TARGET_AIR_PURIFIER_STATE) + serv_air_purifier = self.add_preload_service(SERV_AIR_PURIFIER, self.chars) + self.set_primary_service(serv_air_purifier) + + self.char_active: Characteristic = serv_air_purifier.configure_char( + CHAR_ACTIVE, value=0 + ) + + self.preset_mode_chars: dict[str, Characteristic] + self.char_current_humidity: Characteristic | None = None + self.char_pm25_density: Characteristic | None = None + self.char_current_temperature: Characteristic | None = None + self.char_filter_change_indication: Characteristic | None = None + self.char_filter_life_level: Characteristic | None = None + + self.char_target_air_purifier_state: Characteristic = ( + serv_air_purifier.configure_char( + CHAR_TARGET_AIR_PURIFIER_STATE, + value=0, + ) + ) + + self.char_current_air_purifier_state: Characteristic = ( + serv_air_purifier.configure_char( + CHAR_CURRENT_AIR_PURIFIER_STATE, + value=0, + ) + ) + + self.linked_humidity_sensor = self.config.get(CONF_LINKED_HUMIDITY_SENSOR) + if self.linked_humidity_sensor: + humidity_serv = self.add_preload_service(SERV_HUMIDITY_SENSOR, CHAR_NAME) + serv_air_purifier.add_linked_service(humidity_serv) + self.char_current_humidity = humidity_serv.configure_char( + CHAR_CURRENT_HUMIDITY, value=0 + ) + + humidity_state = self.hass.states.get(self.linked_humidity_sensor) + if humidity_state: + self._async_update_current_humidity(humidity_state) + + self.linked_pm25_sensor = self.config.get(CONF_LINKED_PM25_SENSOR) + if self.linked_pm25_sensor: + pm25_serv = self.add_preload_service( + SERV_AIR_QUALITY_SENSOR, + [CHAR_AIR_QUALITY, CHAR_NAME, CHAR_PM25_DENSITY], + ) + serv_air_purifier.add_linked_service(pm25_serv) + self.char_pm25_density = pm25_serv.configure_char( + CHAR_PM25_DENSITY, value=0 + ) + + self.char_air_quality = pm25_serv.configure_char(CHAR_AIR_QUALITY) + + pm25_state = self.hass.states.get(self.linked_pm25_sensor) + if pm25_state: + self._async_update_current_pm25(pm25_state) + + self.linked_temperature_sensor = self.config.get(CONF_LINKED_TEMPERATURE_SENSOR) + if self.linked_temperature_sensor: + temperature_serv = self.add_preload_service( + SERV_TEMPERATURE_SENSOR, [CHAR_NAME, CHAR_CURRENT_TEMPERATURE] + ) + serv_air_purifier.add_linked_service(temperature_serv) + self.char_current_temperature = temperature_serv.configure_char( + CHAR_CURRENT_TEMPERATURE, value=0 + ) + + temperature_state = self.hass.states.get(self.linked_temperature_sensor) + if temperature_state: + self._async_update_current_temperature(temperature_state) + + self.linked_filter_change_indicator_binary_sensor = self.config.get( + CONF_LINKED_FILTER_CHANGE_INDICATION + ) + self.linked_filter_life_level_sensor = self.config.get( + CONF_LINKED_FILTER_LIFE_LEVEL + ) + if ( + self.linked_filter_change_indicator_binary_sensor + or self.linked_filter_life_level_sensor + ): + chars = [CHAR_NAME, CHAR_FILTER_CHANGE_INDICATION] + if self.linked_filter_life_level_sensor: + chars.append(CHAR_FILTER_LIFE_LEVEL) + serv_filter_maintenance = self.add_preload_service( + SERV_FILTER_MAINTENANCE, chars + ) + serv_air_purifier.add_linked_service(serv_filter_maintenance) + serv_filter_maintenance.configure_char( + CHAR_NAME, + value=cleanup_name_for_homekit(f"{self.display_name} Filter"), + ) + + self.char_filter_change_indication = serv_filter_maintenance.configure_char( + CHAR_FILTER_CHANGE_INDICATION, + value=0, + ) + + if self.linked_filter_change_indicator_binary_sensor: + filter_change_indicator_state = self.hass.states.get( + self.linked_filter_change_indicator_binary_sensor + ) + if filter_change_indicator_state: + self._async_update_filter_change_indicator( + filter_change_indicator_state + ) + + if self.linked_filter_life_level_sensor: + self.char_filter_life_level = serv_filter_maintenance.configure_char( + CHAR_FILTER_LIFE_LEVEL, + value=0, + ) + + filter_life_level_state = self.hass.states.get( + self.linked_filter_life_level_sensor + ) + if filter_life_level_state: + self._async_update_filter_life_level(filter_life_level_state) + + return serv_air_purifier + + def should_add_preset_mode_switch(self, preset_mode: str) -> bool: + """Check if a preset mode switch should be added.""" + return preset_mode.lower() != "auto" + + @callback + @pyhap_callback # type: ignore[misc] + def run(self) -> None: + """Handle accessory driver started event. + + Run inside the Home Assistant event loop. + """ + if self.linked_humidity_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_humidity_sensor], + self._async_update_current_humidity_event, + job_type=HassJobType.Callback, + ) + ) + + if self.linked_pm25_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_pm25_sensor], + self._async_update_current_pm25_event, + job_type=HassJobType.Callback, + ) + ) + + if self.linked_temperature_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_temperature_sensor], + self._async_update_current_temperature_event, + job_type=HassJobType.Callback, + ) + ) + + if self.linked_filter_change_indicator_binary_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_filter_change_indicator_binary_sensor], + self._async_update_filter_change_indicator_event, + job_type=HassJobType.Callback, + ) + ) + + if self.linked_filter_life_level_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_filter_life_level_sensor], + self._async_update_filter_life_level_event, + job_type=HassJobType.Callback, + ) + ) + + super().run() + + @callback + def _async_update_current_humidity_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_current_humidity(event.data["new_state"]) + + @callback + def _async_update_current_humidity(self, new_state: State | None) -> None: + """Handle linked humidity sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + if ( + (current_humidity := convert_to_float(new_state.state)) is None + or not self.char_current_humidity + or self.char_current_humidity.value == current_humidity + ): + return + + _LOGGER.debug( + "%s: Linked humidity sensor %s changed to %d", + self.entity_id, + self.linked_humidity_sensor, + current_humidity, + ) + self.char_current_humidity.set_value(current_humidity) + + @callback + def _async_update_current_pm25_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_current_pm25(event.data["new_state"]) + + @callback + def _async_update_current_pm25(self, new_state: State | None) -> None: + """Handle linked pm25 sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + if ( + (current_pm25 := convert_to_float(new_state.state)) is None + or not self.char_pm25_density + or self.char_pm25_density.value == current_pm25 + ): + return + + _LOGGER.debug( + "%s: Linked pm25 sensor %s changed to %d", + self.entity_id, + self.linked_pm25_sensor, + current_pm25, + ) + self.char_pm25_density.set_value(current_pm25) + air_quality = density_to_air_quality(current_pm25) + self.char_air_quality.set_value(air_quality) + _LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality) + + @callback + def _async_update_current_temperature_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_current_temperature(event.data["new_state"]) + + @callback + def _async_update_current_temperature(self, new_state: State | None) -> None: + """Handle linked temperature sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + if ( + (current_temperature := convert_to_float(new_state.state)) is None + or not self.char_current_temperature + or self.char_current_temperature.value == current_temperature + ): + return + + _LOGGER.debug( + "%s: Linked temperature sensor %s changed to %d", + self.entity_id, + self.linked_temperature_sensor, + current_temperature, + ) + self.char_current_temperature.set_value(current_temperature) + + @callback + def _async_update_filter_change_indicator_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_filter_change_indicator(event.data.get("new_state")) + + @callback + def _async_update_filter_change_indicator(self, new_state: State | None) -> None: + """Handle linked filter change indicator binary sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + current_change_indicator = ( + FILTER_CHANGE_FILTER if new_state.state == "on" else FILTER_OK + ) + if ( + not self.char_filter_change_indication + or self.char_filter_change_indication.value == current_change_indicator + ): + return + + _LOGGER.debug( + "%s: Linked filter change indicator binary sensor %s changed to %d", + self.entity_id, + self.linked_filter_change_indicator_binary_sensor, + current_change_indicator, + ) + self.char_filter_change_indication.set_value(current_change_indicator) + + @callback + def _async_update_filter_life_level_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_filter_life_level(event.data.get("new_state")) + + @callback + def _async_update_filter_life_level(self, new_state: State | None) -> None: + """Handle linked filter life level sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + if ( + (current_life_level := convert_to_float(new_state.state)) is not None + and self.char_filter_life_level + and self.char_filter_life_level.value != current_life_level + ): + _LOGGER.debug( + "%s: Linked filter life level sensor %s changed to %d", + self.entity_id, + self.linked_filter_life_level_sensor, + current_life_level, + ) + self.char_filter_life_level.set_value(current_life_level) + + if self.linked_filter_change_indicator_binary_sensor or not current_life_level: + # Handled by its own event listener + return + + current_change_indicator = ( + FILTER_CHANGE_FILTER + if (current_life_level < THRESHOLD_FILTER_CHANGE_NEEDED) + else FILTER_OK + ) + if ( + not self.char_filter_change_indication + or self.char_filter_change_indication.value == current_change_indicator + ): + return + + _LOGGER.debug( + "%s: Linked filter life level sensor %s changed to %d", + self.entity_id, + self.linked_filter_life_level_sensor, + current_change_indicator, + ) + self.char_filter_change_indication.set_value(current_change_indicator) + + @callback + def async_update_state(self, new_state: State) -> None: + """Update fan after state change.""" + super().async_update_state(new_state) + # Handle State + state = new_state.state + + if self.char_current_air_purifier_state is not None: + self.char_current_air_purifier_state.set_value( + CURRENT_STATE_PURIFYING_AIR + if state == STATE_ON + else CURRENT_STATE_INACTIVE + ) + + # Automatic mode is represented in HASS by a preset called Auto or auto + attributes = new_state.attributes + if ATTR_PRESET_MODE in attributes: + current_preset_mode = attributes.get(ATTR_PRESET_MODE) + self.char_target_air_purifier_state.set_value( + TARGET_STATE_AUTO + if current_preset_mode and current_preset_mode.lower() == "auto" + else TARGET_STATE_MANUAL + ) + + def set_chars(self, char_values: dict[str, Any]) -> None: + """Handle automatic mode after state change.""" + super().set_chars(char_values) + if ( + CHAR_TARGET_AIR_PURIFIER_STATE in char_values + and self.auto_preset is not None + ): + if char_values[CHAR_TARGET_AIR_PURIFIER_STATE] == TARGET_STATE_AUTO: + super().set_preset_mode(True, self.auto_preset) + elif self.char_speed is not None: + super().set_chars({CHAR_ROTATION_SPEED: self.char_speed.get_value()}) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 542d4500cbc..5c91dd0c3bb 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -4,6 +4,7 @@ import logging from typing import Any from pyhap.const import CATEGORY_FAN +from pyhap.service import Service from homeassistant.components.fan import ( ATTR_DIRECTION, @@ -34,6 +35,7 @@ from homeassistant.core import State, callback from .accessories import TYPES, HomeAccessory from .const import ( CHAR_ACTIVE, + CHAR_CONFIGURED_NAME, CHAR_NAME, CHAR_ON, CHAR_ROTATION_DIRECTION, @@ -56,9 +58,9 @@ class Fan(HomeAccessory): Currently supports: state, speed, oscillate, direction. """ - def __init__(self, *args: Any) -> None: + def __init__(self, *args: Any, category: int = CATEGORY_FAN) -> None: """Initialize a new Fan accessory object.""" - super().__init__(*args, category=CATEGORY_FAN) + super().__init__(*args, category=category) self.chars: list[str] = [] state = self.hass.states.get(self.entity_id) assert state @@ -79,12 +81,8 @@ class Fan(HomeAccessory): self.chars.append(CHAR_SWING_MODE) if features & FanEntityFeature.SET_SPEED: self.chars.append(CHAR_ROTATION_SPEED) - if self.preset_modes and len(self.preset_modes) == 1: - self.chars.append(CHAR_TARGET_FAN_STATE) - serv_fan = self.add_preload_service(SERV_FANV2, self.chars) - self.set_primary_service(serv_fan) - self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0) + serv_fan = self.create_services() self.char_direction = None self.char_speed = None @@ -107,15 +105,25 @@ class Fan(HomeAccessory): properties={PROP_MIN_STEP: percentage_step}, ) - if self.preset_modes and len(self.preset_modes) == 1: + if ( + self.preset_modes + and len(self.preset_modes) == 1 + # NOTE: This would be missing for air purifiers + and CHAR_TARGET_FAN_STATE in self.chars + ): self.char_target_fan_state = serv_fan.configure_char( CHAR_TARGET_FAN_STATE, value=0, ) elif self.preset_modes: for preset_mode in self.preset_modes: + if not self.should_add_preset_mode_switch(preset_mode): + continue + preset_serv = self.add_preload_service( - SERV_SWITCH, CHAR_NAME, unique_id=preset_mode + SERV_SWITCH, + [CHAR_NAME, CHAR_CONFIGURED_NAME], + unique_id=preset_mode, ) serv_fan.add_linked_service(preset_serv) preset_serv.configure_char( @@ -124,9 +132,12 @@ class Fan(HomeAccessory): f"{self.display_name} {preset_mode}" ), ) + preset_serv.configure_char( + CHAR_CONFIGURED_NAME, value=cleanup_name_for_homekit(preset_mode) + ) def setter_callback(value: int, preset_mode: str = preset_mode) -> None: - return self.set_preset_mode(value, preset_mode) + self.set_preset_mode(value, preset_mode) self.preset_mode_chars[preset_mode] = preset_serv.configure_char( CHAR_ON, @@ -137,10 +148,27 @@ class Fan(HomeAccessory): if CHAR_SWING_MODE in self.chars: self.char_swing = serv_fan.configure_char(CHAR_SWING_MODE, value=0) self.async_update_state(state) - serv_fan.setter_callback = self._set_chars + serv_fan.setter_callback = self.set_chars - def _set_chars(self, char_values: dict[str, Any]) -> None: - _LOGGER.debug("Fan _set_chars: %s", char_values) + def create_services(self) -> Service: + """Create and configure the primary service for this accessory.""" + if self.preset_modes and len(self.preset_modes) == 1: + self.chars.append(CHAR_TARGET_FAN_STATE) + serv_fan = self.add_preload_service(SERV_FANV2, self.chars) + self.set_primary_service(serv_fan) + self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0) + return serv_fan + + def should_add_preset_mode_switch(self, preset_mode: str) -> bool: + """Check if a preset mode switch should be added. + + Always true for fans, but can be overridden by subclasses. + """ + return True + + def set_chars(self, char_values: dict[str, Any]) -> None: + """Set characteristic values.""" + _LOGGER.debug("Fan set_chars: %s", char_values) if CHAR_ACTIVE in char_values: if char_values[CHAR_ACTIVE]: # If the device supports set speed we diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index adb16da5a2d..88d227d0ca5 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -41,6 +41,7 @@ from .const import ( ATTR_KEY_NAME, CATEGORY_RECEIVER, CHAR_ACTIVE, + CHAR_CONFIGURED_NAME, CHAR_MUTE, CHAR_NAME, CHAR_ON, @@ -100,41 +101,67 @@ class MediaPlayer(HomeAccessory): ) if FEATURE_ON_OFF in feature_list: - name = self.generate_service_name(FEATURE_ON_OFF) serv_on_off = self.add_preload_service( - SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_ON_OFF + SERV_SWITCH, [CHAR_CONFIGURED_NAME, CHAR_NAME], unique_id=FEATURE_ON_OFF + ) + serv_on_off.configure_char( + CHAR_NAME, value=self.generate_service_name(FEATURE_ON_OFF) + ) + serv_on_off.configure_char( + CHAR_CONFIGURED_NAME, + value=self.generated_configured_name(FEATURE_ON_OFF), ) - serv_on_off.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_ON_OFF] = serv_on_off.configure_char( CHAR_ON, value=False, setter_callback=self.set_on_off ) if FEATURE_PLAY_PAUSE in feature_list: - name = self.generate_service_name(FEATURE_PLAY_PAUSE) serv_play_pause = self.add_preload_service( - SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_PLAY_PAUSE + SERV_SWITCH, + [CHAR_CONFIGURED_NAME, CHAR_NAME], + unique_id=FEATURE_PLAY_PAUSE, + ) + serv_play_pause.configure_char( + CHAR_NAME, value=self.generate_service_name(FEATURE_PLAY_PAUSE) + ) + serv_play_pause.configure_char( + CHAR_CONFIGURED_NAME, + value=self.generated_configured_name(FEATURE_PLAY_PAUSE), ) - serv_play_pause.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_PLAY_PAUSE] = serv_play_pause.configure_char( CHAR_ON, value=False, setter_callback=self.set_play_pause ) if FEATURE_PLAY_STOP in feature_list: - name = self.generate_service_name(FEATURE_PLAY_STOP) serv_play_stop = self.add_preload_service( - SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_PLAY_STOP + SERV_SWITCH, + [CHAR_CONFIGURED_NAME, CHAR_NAME], + unique_id=FEATURE_PLAY_STOP, + ) + serv_play_stop.configure_char( + CHAR_NAME, value=self.generate_service_name(FEATURE_PLAY_STOP) + ) + serv_play_stop.configure_char( + CHAR_CONFIGURED_NAME, + value=self.generated_configured_name(FEATURE_PLAY_STOP), ) - serv_play_stop.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_PLAY_STOP] = serv_play_stop.configure_char( CHAR_ON, value=False, setter_callback=self.set_play_stop ) if FEATURE_TOGGLE_MUTE in feature_list: - name = self.generate_service_name(FEATURE_TOGGLE_MUTE) serv_toggle_mute = self.add_preload_service( - SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_TOGGLE_MUTE + SERV_SWITCH, + [CHAR_CONFIGURED_NAME, CHAR_NAME], + unique_id=FEATURE_TOGGLE_MUTE, + ) + serv_toggle_mute.configure_char( + CHAR_NAME, value=self.generate_service_name(FEATURE_TOGGLE_MUTE) + ) + serv_toggle_mute.configure_char( + CHAR_CONFIGURED_NAME, + value=self.generated_configured_name(FEATURE_TOGGLE_MUTE), ) - serv_toggle_mute.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_TOGGLE_MUTE] = serv_toggle_mute.configure_char( CHAR_ON, value=False, setter_callback=self.set_toggle_mute ) @@ -146,6 +173,10 @@ class MediaPlayer(HomeAccessory): f"{self.display_name} {MODE_FRIENDLY_NAME[mode]}" ) + def generated_configured_name(self, mode: str) -> str: + """Generate name for individual service.""" + return cleanup_name_for_homekit(MODE_FRIENDLY_NAME[mode]) + def set_on_off(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 8c6fc1ed672..18150c820c3 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -49,6 +49,7 @@ from homeassistant.helpers.event import async_call_later from .accessories import TYPES, HomeAccessory, HomeDriver from .const import ( CHAR_ACTIVE, + CHAR_CONFIGURED_NAME, CHAR_IN_USE, CHAR_NAME, CHAR_ON, @@ -360,11 +361,13 @@ class SelectSwitch(HomeAccessory): options = state.attributes[ATTR_OPTIONS] for option in options: serv_option = self.add_preload_service( - SERV_OUTLET, [CHAR_NAME, CHAR_IN_USE], unique_id=option - ) - serv_option.configure_char( - CHAR_NAME, value=cleanup_name_for_homekit(option) + SERV_OUTLET, + [CHAR_NAME, CHAR_CONFIGURED_NAME, CHAR_IN_USE], + unique_id=option, ) + name = cleanup_name_for_homekit(option) + serv_option.configure_char(CHAR_NAME, value=name) + serv_option.configure_char(CHAR_CONFIGURED_NAME, value=name) serv_option.configure_char(CHAR_IN_USE, value=False) self.select_chars[option] = serv_option.configure_char( CHAR_ON, diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index f32c4f55a0f..44db65d7b0b 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -15,6 +15,7 @@ from homeassistant.helpers.trigger import async_initialize_triggers from .accessories import TYPES, HomeAccessory from .aidmanager import get_system_unique_id from .const import ( + CHAR_CONFIGURED_NAME, CHAR_NAME, CHAR_PROGRAMMABLE_SWITCH_EVENT, CHAR_SERVICE_LABEL_INDEX, @@ -66,7 +67,7 @@ class DeviceTriggerAccessory(HomeAccessory): trigger_name = cleanup_name_for_homekit(" ".join(trigger_name_parts)) serv_stateless_switch = self.add_preload_service( SERV_STATELESS_PROGRAMMABLE_SWITCH, - [CHAR_NAME, CHAR_SERVICE_LABEL_INDEX], + [CHAR_NAME, CHAR_CONFIGURED_NAME, CHAR_SERVICE_LABEL_INDEX], unique_id=unique_id, ) self.triggers.append( @@ -77,6 +78,9 @@ class DeviceTriggerAccessory(HomeAccessory): ) ) serv_stateless_switch.configure_char(CHAR_NAME, value=trigger_name) + serv_stateless_switch.configure_char( + CHAR_CONFIGURED_NAME, value=trigger_name + ) serv_stateless_switch.configure_char( CHAR_SERVICE_LABEL_INDEX, value=idx + 1 ) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 1181ceaa953..bc98f00c15a 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -62,9 +62,13 @@ from .const import ( CONF_LINKED_BATTERY_CHARGING_SENSOR, CONF_LINKED_BATTERY_SENSOR, CONF_LINKED_DOORBELL_SENSOR, + CONF_LINKED_FILTER_CHANGE_INDICATION, + CONF_LINKED_FILTER_LIFE_LEVEL, CONF_LINKED_HUMIDITY_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_LINKED_OBSTRUCTION_SENSOR, + CONF_LINKED_PM25_SENSOR, + CONF_LINKED_TEMPERATURE_SENSOR, CONF_LOW_BATTERY_THRESHOLD, CONF_MAX_FPS, CONF_MAX_HEIGHT, @@ -98,6 +102,8 @@ from .const import ( FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, MAX_NAME_LENGTH, + TYPE_AIR_PURIFIER, + TYPE_FAN, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -187,6 +193,27 @@ HUMIDIFIER_SCHEMA = BASIC_INFO_SCHEMA.extend( {vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN)} ) +FAN_SCHEMA = BASIC_INFO_SCHEMA.extend( + { + vol.Optional(CONF_TYPE, default=TYPE_FAN): vol.All( + cv.string, + vol.In( + ( + TYPE_FAN, + TYPE_AIR_PURIFIER, + ) + ), + ), + vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN), + vol.Optional(CONF_LINKED_PM25_SENSOR): cv.entity_domain(sensor.DOMAIN), + vol.Optional(CONF_LINKED_TEMPERATURE_SENSOR): cv.entity_domain(sensor.DOMAIN), + vol.Optional(CONF_LINKED_FILTER_CHANGE_INDICATION): cv.entity_domain( + binary_sensor.DOMAIN + ), + vol.Optional(CONF_LINKED_FILTER_LIFE_LEVEL): cv.entity_domain(sensor.DOMAIN), + } +) + COVER_SCHEMA = BASIC_INFO_SCHEMA.extend( { vol.Optional(CONF_LINKED_OBSTRUCTION_SENSOR): cv.entity_domain( @@ -325,6 +352,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]: elif domain == "cover": config = COVER_SCHEMA(config) + elif domain == "fan": + config = FAN_SCHEMA(config) + elif domain == "sensor": config = SENSOR_SCHEMA(config) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 43cbdec67fa..931bd40d64c 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -9,10 +9,11 @@ from functools import partial import logging from operator import attrgetter from types import MappingProxyType -from typing import Any +from typing import Any, cast from aiohomekit import Controller from aiohomekit.controller import TransportType +from aiohomekit.controller.ble.discovery import BleDiscovery from aiohomekit.exceptions import ( AccessoryDisconnectedError, AccessoryNotFoundError, @@ -372,6 +373,16 @@ class HKDevice: if not self.unreliable_serial_numbers: identifiers.add((IDENTIFIER_SERIAL_NUMBER, accessory.serial_number)) + connections: set[tuple[str, str]] = set() + if self.pairing.transport == Transport.BLE and ( + discovery := self.pairing.controller.discoveries.get( + normalize_hkid(self.unique_id) + ) + ): + connections = { + (dr.CONNECTION_BLUETOOTH, cast(BleDiscovery, discovery).device.address), + } + device_info = DeviceInfo( identifiers={ ( @@ -379,6 +390,7 @@ class HKDevice: f"{self.unique_id}:aid:{accessory.aid}", ) }, + connections=connections, name=accessory.name, manufacturer=accessory.manufacturer, model=accessory.model, diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 9ba476a0ef3..4138277d81c 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -5,6 +5,10 @@ from __future__ import annotations from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics.const import ( + TargetAirPurifierStateValues, + TargetFanStateValues, +) from aiohomekit.model.services import Service, ServicesTypes from propcache.api import cached_property @@ -35,6 +39,8 @@ DIRECTION_TO_HK = { } HK_DIRECTION_TO_HA = {v: k for (k, v) in DIRECTION_TO_HK.items()} +PRESET_AUTO = "auto" + class BaseHomeKitFan(HomeKitEntity, FanEntity): """Representation of a Homekit fan.""" @@ -42,6 +48,9 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): # This must be set in subclasses to the name of a boolean characteristic # that controls whether the fan is on or off. on_characteristic: str + preset_char = CharacteristicsTypes.FAN_STATE_TARGET + preset_manual_value: int = TargetFanStateValues.MANUAL + preset_automatic_value: int = TargetFanStateValues.AUTOMATIC @callback def _async_reconfigure(self) -> None: @@ -51,6 +60,7 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): "_speed_range", "_min_speed", "_max_speed", + "preset_modes", "speed_count", "supported_features", ) @@ -59,12 +69,15 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" - return [ + types = [ CharacteristicsTypes.SWING_MODE, CharacteristicsTypes.ROTATION_DIRECTION, CharacteristicsTypes.ROTATION_SPEED, self.on_characteristic, ] + if self.service.has(self.preset_char): + types.append(self.preset_char) + return types @property def is_on(self) -> bool: @@ -124,6 +137,9 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): if self.service.has(CharacteristicsTypes.SWING_MODE): features |= FanEntityFeature.OSCILLATE + if self.service.has(self.preset_char): + features |= FanEntityFeature.PRESET_MODE + return features @cached_property @@ -134,6 +150,32 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): / max(1, self.service[CharacteristicsTypes.ROTATION_SPEED].minStep or 0) ) + @cached_property + def preset_modes(self) -> list[str]: + """Return the preset modes.""" + return [PRESET_AUTO] if self.service.has(self.preset_char) else [] + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + if ( + self.service.has(self.preset_char) + and self.service.value(self.preset_char) == self.preset_automatic_value + ): + return PRESET_AUTO + return None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + if self.service.has(self.preset_char): + await self.async_put_characteristics( + { + self.preset_char: self.preset_automatic_value + if preset_mode == PRESET_AUTO + else self.preset_manual_value + } + ) + async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" await self.async_put_characteristics( @@ -146,13 +188,16 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): await self.async_turn_off() return - await self.async_put_characteristics( - { - CharacteristicsTypes.ROTATION_SPEED: round( - percentage_to_ranged_value(self._speed_range, percentage) - ) - } - ) + characteristics = { + CharacteristicsTypes.ROTATION_SPEED: round( + percentage_to_ranged_value(self._speed_range, percentage) + ) + } + + if FanEntityFeature.PRESET_MODE in self.supported_features: + characteristics[self.preset_char] = self.preset_manual_value + + await self.async_put_characteristics(characteristics) async def async_oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" @@ -172,13 +217,17 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): if not self.is_on: characteristics[self.on_characteristic] = True - if ( + if preset_mode == PRESET_AUTO: + characteristics[self.preset_char] = self.preset_automatic_value + elif ( percentage is not None and FanEntityFeature.SET_SPEED in self.supported_features ): characteristics[CharacteristicsTypes.ROTATION_SPEED] = round( percentage_to_ranged_value(self._speed_range, percentage) ) + if FanEntityFeature.PRESET_MODE in self.supported_features: + characteristics[self.preset_char] = self.preset_manual_value if characteristics: await self.async_put_characteristics(characteristics) @@ -200,10 +249,18 @@ class HomeKitFanV2(BaseHomeKitFan): on_characteristic = CharacteristicsTypes.ACTIVE +class HomeKitAirPurifer(HomeKitFanV2): + """Implement air purifier support for public.hap.service.airpurifier.""" + + preset_char = CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET + preset_manual_value = TargetAirPurifierStateValues.MANUAL + preset_automatic_value = TargetAirPurifierStateValues.AUTOMATIC + + ENTITY_TYPES = { ServicesTypes.FAN: HomeKitFanV1, ServicesTypes.FAN_V2: HomeKitFanV2, - ServicesTypes.AIR_PURIFIER: HomeKitFanV2, + ServicesTypes.AIR_PURIFIER: HomeKitAirPurifer, } diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index d1205645fd3..e857e1a7f01 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -14,7 +14,7 @@ "title": "Pair with a device via HomeKit Accessory Protocol", "description": "HomeKit Device communicates with {name} ({category}) over the local area network using a secure encrypted connection without a separate HomeKit Controller or iCloud. Enter your eight digit HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging, often close to a HomeKit bar code, next to the image of a small house.", "data": { - "pairing_code": "Pairing Code", + "pairing_code": "Pairing code", "allow_insecure_setup_codes": "Allow pairing with insecure setup codes." } }, @@ -112,7 +112,7 @@ "air_purifier_state_target": { "state": { "automatic": "Automatic", - "manual": "Manual" + "manual": "[%key:common::state::manual%]" } } }, @@ -141,7 +141,7 @@ "air_purifier_state_current": { "state": { "inactive": "Inactive", - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "purifying": "Purifying" } } diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index d5b084644e3..af57d8b0cd0 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -82,15 +82,15 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - await self._home.set_security_zones_activation(False, False) + await self._home.set_security_zones_activation_async(False, False) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - await self._home.set_security_zones_activation(False, True) + await self._home.set_security_zones_activation_async(False, True) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - await self._home.set_security_zones_activation(True, True) + await self._home.set_security_zones_activation_async(True, True) async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index f0cd3732718..e135e95634d 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -4,31 +4,31 @@ from __future__ import annotations from typing import Any -from homematicip.aio.device import ( - AsyncAccelerationSensor, - AsyncContactInterface, - AsyncDevice, - AsyncFullFlushContactInterface, - AsyncFullFlushContactInterface6, - AsyncMotionDetectorIndoor, - AsyncMotionDetectorOutdoor, - AsyncMotionDetectorPushButton, - AsyncPluggableMainsFailureSurveillance, - AsyncPresenceDetectorIndoor, - AsyncRainSensor, - AsyncRotaryHandleSensor, - AsyncShutterContact, - AsyncShutterContactMagnetic, - AsyncSmokeDetector, - AsyncTiltVibrationSensor, - AsyncWaterSensor, - AsyncWeatherSensor, - AsyncWeatherSensorPlus, - AsyncWeatherSensorPro, - AsyncWiredInput32, -) -from homematicip.aio.group import AsyncSecurityGroup, AsyncSecurityZoneGroup from homematicip.base.enums import SmokeDetectorAlarmType, WindowState +from homematicip.device import ( + AccelerationSensor, + ContactInterface, + Device, + FullFlushContactInterface, + FullFlushContactInterface6, + MotionDetectorIndoor, + MotionDetectorOutdoor, + MotionDetectorPushButton, + PluggableMainsFailureSurveillance, + PresenceDetectorIndoor, + RainSensor, + RotaryHandleSensor, + ShutterContact, + ShutterContactMagnetic, + SmokeDetector, + TiltVibrationSensor, + WaterSensor, + WeatherSensor, + WeatherSensorPlus, + WeatherSensorPro, + WiredInput32, +) +from homematicip.group import SecurityGroup, SecurityZoneGroup from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -82,66 +82,60 @@ async def async_setup_entry( hap = hass.data[DOMAIN][config_entry.unique_id] entities: list[HomematicipGenericEntity] = [HomematicipCloudConnectionSensor(hap)] for device in hap.home.devices: - if isinstance(device, AsyncAccelerationSensor): + if isinstance(device, AccelerationSensor): entities.append(HomematicipAccelerationSensor(hap, device)) - if isinstance(device, AsyncTiltVibrationSensor): + if isinstance(device, TiltVibrationSensor): entities.append(HomematicipTiltVibrationSensor(hap, device)) - if isinstance(device, AsyncWiredInput32): + if isinstance(device, WiredInput32): entities.extend( HomematicipMultiContactInterface(hap, device, channel=channel) for channel in range(1, 33) ) - elif isinstance(device, AsyncFullFlushContactInterface6): + elif isinstance(device, FullFlushContactInterface6): entities.extend( HomematicipMultiContactInterface(hap, device, channel=channel) for channel in range(1, 7) ) - elif isinstance( - device, (AsyncContactInterface, AsyncFullFlushContactInterface) - ): + elif isinstance(device, (ContactInterface, FullFlushContactInterface)): entities.append(HomematicipContactInterface(hap, device)) if isinstance( device, - (AsyncShutterContact, AsyncShutterContactMagnetic), + (ShutterContact, ShutterContactMagnetic), ): entities.append(HomematicipShutterContact(hap, device)) - if isinstance(device, AsyncRotaryHandleSensor): + if isinstance(device, RotaryHandleSensor): entities.append(HomematicipShutterContact(hap, device, True)) if isinstance( device, ( - AsyncMotionDetectorIndoor, - AsyncMotionDetectorOutdoor, - AsyncMotionDetectorPushButton, + MotionDetectorIndoor, + MotionDetectorOutdoor, + MotionDetectorPushButton, ), ): entities.append(HomematicipMotionDetector(hap, device)) - if isinstance(device, AsyncPluggableMainsFailureSurveillance): + if isinstance(device, PluggableMainsFailureSurveillance): entities.append( HomematicipPluggableMainsFailureSurveillanceSensor(hap, device) ) - if isinstance(device, AsyncPresenceDetectorIndoor): + if isinstance(device, PresenceDetectorIndoor): entities.append(HomematicipPresenceDetector(hap, device)) - if isinstance(device, AsyncSmokeDetector): + if isinstance(device, SmokeDetector): entities.append(HomematicipSmokeDetector(hap, device)) - if isinstance(device, AsyncWaterSensor): + if isinstance(device, WaterSensor): entities.append(HomematicipWaterDetector(hap, device)) - if isinstance( - device, (AsyncRainSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) - ): + if isinstance(device, (RainSensor, WeatherSensorPlus, WeatherSensorPro)): entities.append(HomematicipRainSensor(hap, device)) - if isinstance( - device, (AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) - ): + if isinstance(device, (WeatherSensor, WeatherSensorPlus, WeatherSensorPro)): entities.append(HomematicipStormSensor(hap, device)) entities.append(HomematicipSunshineSensor(hap, device)) - if isinstance(device, AsyncDevice) and device.lowBat is not None: + if isinstance(device, Device) and device.lowBat is not None: entities.append(HomematicipBatterySensor(hap, device)) for group in hap.home.groups: - if isinstance(group, AsyncSecurityGroup): + if isinstance(group, SecurityGroup): entities.append(HomematicipSecuritySensorGroup(hap, device=group)) - elif isinstance(group, AsyncSecurityZoneGroup): + elif isinstance(group, SecurityZoneGroup): entities.append(HomematicipSecurityZoneSensorGroup(hap, device=group)) async_add_entities(entities) diff --git a/homeassistant/components/homematicip_cloud/button.py b/homeassistant/components/homematicip_cloud/button.py index fedc271714c..0d70ad53d54 100644 --- a/homeassistant/components/homematicip_cloud/button.py +++ b/homeassistant/components/homematicip_cloud/button.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homematicip.aio.device import AsyncWallMountedGarageDoorController +from homematicip.device import WallMountedGarageDoorController from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry @@ -25,7 +25,7 @@ async def async_setup_entry( async_add_entities( HomematicipGarageDoorControllerButton(hap, device) for device in hap.home.devices - if isinstance(device, AsyncWallMountedGarageDoorController) + if isinstance(device, WallMountedGarageDoorController) ) @@ -39,4 +39,4 @@ class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEnti async def async_press(self) -> None: """Handle the button press.""" - await self._device.send_start_impulse() + await self._device.send_start_impulse_async() diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 35bd18ff438..0952f17d3ec 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -4,16 +4,15 @@ from __future__ import annotations from typing import Any -from homematicip.aio.device import ( - AsyncHeatingThermostat, - AsyncHeatingThermostatCompact, - AsyncHeatingThermostatEvo, -) -from homematicip.aio.group import AsyncHeatingGroup from homematicip.base.enums import AbsenceType -from homematicip.device import Switch +from homematicip.device import ( + HeatingThermostat, + HeatingThermostatCompact, + HeatingThermostatEvo, + Switch, +) from homematicip.functionalHomes import IndoorClimateHome -from homematicip.group import HeatingCoolingProfile +from homematicip.group import HeatingCoolingProfile, HeatingGroup from homeassistant.components.climate import ( PRESET_AWAY, @@ -65,7 +64,7 @@ async def async_setup_entry( async_add_entities( HomematicipHeatingGroup(hap, device) for device in hap.home.groups - if isinstance(device, AsyncHeatingGroup) + if isinstance(device, HeatingGroup) ) @@ -82,7 +81,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, hap: HomematicipHAP, device: AsyncHeatingGroup) -> None: + def __init__(self, hap: HomematicipHAP, device: HeatingGroup) -> None: """Initialize heating group.""" device.modelType = "HmIP-Heating-Group" super().__init__(hap, device) @@ -214,7 +213,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): return if self.min_temp <= temperature <= self.max_temp: - await self._device.set_point_temperature(temperature) + await self._device.set_point_temperature_async(temperature) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" @@ -222,23 +221,23 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): return if hvac_mode == HVACMode.AUTO: - await self._device.set_control_mode(HMIP_AUTOMATIC_CM) + await self._device.set_control_mode_async(HMIP_AUTOMATIC_CM) else: - await self._device.set_control_mode(HMIP_MANUAL_CM) + await self._device.set_control_mode_async(HMIP_MANUAL_CM) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" if self._device.boostMode and preset_mode != PRESET_BOOST: - await self._device.set_boost(False) + await self._device.set_boost_async(False) if preset_mode == PRESET_BOOST: - await self._device.set_boost() + await self._device.set_boost_async() if preset_mode == PRESET_ECO: - await self._device.set_control_mode(HMIP_ECO_CM) + await self._device.set_control_mode_async(HMIP_ECO_CM) if preset_mode in self._device_profile_names: profile_idx = self._get_profile_idx_by_name(preset_mode) if self._device.controlMode != HMIP_AUTOMATIC_CM: await self.async_set_hvac_mode(HVACMode.AUTO) - await self._device.set_active_profile(profile_idx) + await self._device.set_active_profile_async(profile_idx) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -332,20 +331,15 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): @property def _first_radiator_thermostat( self, - ) -> ( - AsyncHeatingThermostat - | AsyncHeatingThermostatCompact - | AsyncHeatingThermostatEvo - | None - ): + ) -> HeatingThermostat | HeatingThermostatCompact | HeatingThermostatEvo | None: """Return the first radiator thermostat from the hmip heating group.""" for device in self._device.devices: if isinstance( device, ( - AsyncHeatingThermostat, - AsyncHeatingThermostatCompact, - AsyncHeatingThermostatEvo, + HeatingThermostat, + HeatingThermostatCompact, + HeatingThermostatEvo, ), ): return device diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index 27a84abb572..317024658e1 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -4,16 +4,16 @@ from __future__ import annotations from typing import Any -from homematicip.aio.device import ( - AsyncBlindModule, - AsyncDinRailBlind4, - AsyncFullFlushBlind, - AsyncFullFlushShutter, - AsyncGarageDoorModuleTormatic, - AsyncHoermannDrivesModule, -) -from homematicip.aio.group import AsyncExtendedLinkedShutterGroup from homematicip.base.enums import DoorCommand, DoorState +from homematicip.device import ( + BlindModule, + DinRailBlind4, + FullFlushBlind, + FullFlushShutter, + GarageDoorModuleTormatic, + HoermannDrivesModule, +) +from homematicip.group import ExtendedLinkedShutterGroup from homeassistant.components.cover import ( ATTR_POSITION, @@ -45,23 +45,21 @@ async def async_setup_entry( entities: list[HomematicipGenericEntity] = [ HomematicipCoverShutterGroup(hap, group) for group in hap.home.groups - if isinstance(group, AsyncExtendedLinkedShutterGroup) + if isinstance(group, ExtendedLinkedShutterGroup) ] for device in hap.home.devices: - if isinstance(device, AsyncBlindModule): + if isinstance(device, BlindModule): entities.append(HomematicipBlindModule(hap, device)) - elif isinstance(device, AsyncDinRailBlind4): + elif isinstance(device, DinRailBlind4): entities.extend( HomematicipMultiCoverSlats(hap, device, channel=channel) for channel in range(1, 5) ) - elif isinstance(device, AsyncFullFlushBlind): + elif isinstance(device, FullFlushBlind): entities.append(HomematicipCoverSlats(hap, device)) - elif isinstance(device, AsyncFullFlushShutter): + elif isinstance(device, FullFlushShutter): entities.append(HomematicipCoverShutter(hap, device)) - elif isinstance( - device, (AsyncHoermannDrivesModule, AsyncGarageDoorModuleTormatic) - ): + elif isinstance(device, (HoermannDrivesModule, GarageDoorModuleTormatic)): entities.append(HomematicipGarageDoorModule(hap, device)) async_add_entities(entities) @@ -91,14 +89,14 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): position = kwargs[ATTR_POSITION] # HmIP cover is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_primary_shading_level(primaryShadingLevel=level) + await self._device.set_primary_shading_level_async(primaryShadingLevel=level) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover to a specific tilt position.""" position = kwargs[ATTR_TILT_POSITION] # HmIP slats is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_secondary_shading_level( + await self._device.set_secondary_shading_level_async( primaryShadingLevel=self._device.primaryShadingLevel, secondaryShadingLevel=level, ) @@ -112,37 +110,37 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._device.set_primary_shading_level( + await self._device.set_primary_shading_level_async( primaryShadingLevel=HMIP_COVER_OPEN ) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._device.set_primary_shading_level( + await self._device.set_primary_shading_level_async( primaryShadingLevel=HMIP_COVER_CLOSED ) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" - await self._device.stop() + await self._device.stop_async() async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the slats.""" - await self._device.set_secondary_shading_level( + await self._device.set_secondary_shading_level_async( primaryShadingLevel=self._device.primaryShadingLevel, secondaryShadingLevel=HMIP_SLATS_OPEN, ) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the slats.""" - await self._device.set_secondary_shading_level( + await self._device.set_secondary_shading_level_async( primaryShadingLevel=self._device.primaryShadingLevel, secondaryShadingLevel=HMIP_SLATS_CLOSED, ) async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the device if in motion.""" - await self._device.stop() + await self._device.stop_async() class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): @@ -176,7 +174,7 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): position = kwargs[ATTR_POSITION] # HmIP cover is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_shutter_level(level, self._channel) + await self._device.set_shutter_level_async(level, self._channel) @property def is_closed(self) -> bool | None: @@ -190,15 +188,15 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._device.set_shutter_level(HMIP_COVER_OPEN, self._channel) + await self._device.set_shutter_level_async(HMIP_COVER_OPEN, self._channel) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._device.set_shutter_level(HMIP_COVER_CLOSED, self._channel) + await self._device.set_shutter_level_async(HMIP_COVER_CLOSED, self._channel) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" - await self._device.set_shutter_stop(self._channel) + await self._device.set_shutter_stop_async(self._channel) class HomematicipCoverShutter(HomematicipMultiCoverShutter, CoverEntity): @@ -238,23 +236,25 @@ class HomematicipMultiCoverSlats(HomematicipMultiCoverShutter, CoverEntity): position = kwargs[ATTR_TILT_POSITION] # HmIP slats is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_slats_level(slatsLevel=level, channelIndex=self._channel) + await self._device.set_slats_level_async( + slatsLevel=level, channelIndex=self._channel + ) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the slats.""" - await self._device.set_slats_level( + await self._device.set_slats_level_async( slatsLevel=HMIP_SLATS_OPEN, channelIndex=self._channel ) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the slats.""" - await self._device.set_slats_level( + await self._device.set_slats_level_async( slatsLevel=HMIP_SLATS_CLOSED, channelIndex=self._channel ) async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the device if in motion.""" - await self._device.set_shutter_stop(self._channel) + await self._device.set_shutter_stop_async(self._channel) class HomematicipCoverSlats(HomematicipMultiCoverSlats, CoverEntity): @@ -288,15 +288,15 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._device.send_door_command(DoorCommand.OPEN) + await self._device.send_door_command_async(DoorCommand.OPEN) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._device.send_door_command(DoorCommand.CLOSE) + await self._device.send_door_command_async(DoorCommand.CLOSE) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self._device.send_door_command(DoorCommand.STOP) + await self._device.send_door_command_async(DoorCommand.STOP) class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): @@ -335,35 +335,35 @@ class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): position = kwargs[ATTR_POSITION] # HmIP cover is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_shutter_level(level) + await self._device.set_shutter_level_async(level) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover to a specific tilt position.""" position = kwargs[ATTR_TILT_POSITION] # HmIP slats is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_slats_level(level) + await self._device.set_slats_level_async(level) async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._device.set_shutter_level(HMIP_COVER_OPEN) + await self._device.set_shutter_level_async(HMIP_COVER_OPEN) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._device.set_shutter_level(HMIP_COVER_CLOSED) + await self._device.set_shutter_level_async(HMIP_COVER_CLOSED) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the group if in motion.""" - await self._device.set_shutter_stop() + await self._device.set_shutter_stop_async() async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the slats.""" - await self._device.set_slats_level(HMIP_SLATS_OPEN) + await self._device.set_slats_level_async(HMIP_SLATS_OPEN) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the slats.""" - await self._device.set_slats_level(HMIP_SLATS_CLOSED) + await self._device.set_slats_level_async(HMIP_SLATS_CLOSED) async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the group if in motion.""" - await self._device.set_shutter_stop() + await self._device.set_shutter_stop_async() diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py index 82d682b9910..41ccbb4b060 100644 --- a/homeassistant/components/homematicip_cloud/entity.py +++ b/homeassistant/components/homematicip_cloud/entity.py @@ -5,9 +5,9 @@ from __future__ import annotations import logging from typing import Any -from homematicip.aio.device import AsyncDevice -from homematicip.aio.group import AsyncGroup from homematicip.base.functionalChannels import FunctionalChannel +from homematicip.device import Device +from homematicip.group import Group from homeassistant.const import ATTR_ID from homeassistant.core import callback @@ -100,7 +100,7 @@ class HomematicipGenericEntity(Entity): def device_info(self) -> DeviceInfo | None: """Return device specific attributes.""" # Only physical devices should be HA devices. - if isinstance(self._device, AsyncDevice): + if isinstance(self._device, Device): return DeviceInfo( identifiers={ # Serial numbers of Homematic IP device @@ -237,14 +237,14 @@ class HomematicipGenericEntity(Entity): """Return the state attributes of the generic entity.""" state_attr = {} - if isinstance(self._device, AsyncDevice): + if isinstance(self._device, Device): for attr, attr_key in DEVICE_ATTRIBUTES.items(): if attr_value := getattr(self._device, attr, None): state_attr[attr_key] = attr_value state_attr[ATTR_IS_GROUP] = False - if isinstance(self._device, AsyncGroup): + if isinstance(self._device, Group): for attr, attr_key in GROUP_ATTRIBUTES.items(): if attr_value := getattr(self._device, attr, None): state_attr[attr_key] = attr_value diff --git a/homeassistant/components/homematicip_cloud/event.py b/homeassistant/components/homematicip_cloud/event.py index 654f56bb47f..47a5ff46224 100644 --- a/homeassistant/components/homematicip_cloud/event.py +++ b/homeassistant/components/homematicip_cloud/event.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from homematicip.aio.device import Device +from homematicip.device import Device from homeassistant.components.event import ( EventDeviceClass, diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index db7fcb348c8..6f98836a1ff 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -7,15 +7,18 @@ from collections.abc import Callable import logging from typing import Any -from homematicip.aio.auth import AsyncAuth -from homematicip.aio.home import AsyncHome -from homematicip.base.base_connection import HmipConnectionError +from homematicip.async_home import AsyncHome +from homematicip.auth import Auth from homematicip.base.enums import EventType +from homematicip.connection.connection_context import ConnectionContextBuilder +from homematicip.connection.rest_connection import RestConnection +from homematicip.exceptions.connection_exceptions import HmipConnectionError +import homeassistant from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.httpx_client import get_async_client from .const import HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, HMIPC_PIN, PLATFORMS from .errors import HmipcConnectionError @@ -23,10 +26,25 @@ from .errors import HmipcConnectionError _LOGGER = logging.getLogger(__name__) +async def build_context_async( + hass: HomeAssistant, hapid: str | None, authtoken: str | None +): + """Create a HomematicIP context object.""" + ssl_ctx = homeassistant.util.ssl.get_default_context() + client_session = get_async_client(hass) + + return await ConnectionContextBuilder.build_context_async( + accesspoint_id=hapid, + auth_token=authtoken, + ssl_ctx=ssl_ctx, + httpx_client_session=client_session, + ) + + class HomematicipAuth: """Manages HomematicIP client registration.""" - auth: AsyncAuth + auth: Auth def __init__(self, hass: HomeAssistant, config: dict[str, str]) -> None: """Initialize HomematicIP Cloud client registration.""" @@ -46,27 +64,34 @@ class HomematicipAuth: async def async_checkbutton(self) -> bool: """Check blue butten has been pressed.""" try: - return await self.auth.isRequestAcknowledged() + return await self.auth.is_request_acknowledged() except HmipConnectionError: return False async def async_register(self): """Register client at HomematicIP.""" try: - authtoken = await self.auth.requestAuthToken() - await self.auth.confirmAuthToken(authtoken) + authtoken = await self.auth.request_auth_token() + await self.auth.confirm_auth_token(authtoken) except HmipConnectionError: return False return authtoken async def get_auth(self, hass: HomeAssistant, hapid, pin): """Create a HomematicIP access point object.""" - auth = AsyncAuth(hass.loop, async_get_clientsession(hass)) + context = await build_context_async(hass, hapid, None) + connection = RestConnection( + context, + log_status_exceptions=False, + httpx_client_session=get_async_client(hass), + ) + # hass.loop + auth = Auth(connection, context.client_auth_token, hapid) + try: - await auth.init(hapid) - if pin: - auth.pin = pin - await auth.connectionRequest("HomeAssistant") + auth.set_pin(pin) + result = await auth.connection_request(hapid) + _LOGGER.debug("Connection request result: %s", result) except HmipConnectionError: return None return auth @@ -156,7 +181,7 @@ class HomematicipHAP: async def get_state(self) -> None: """Update HMIP state and tell Home Assistant.""" - await self.home.get_current_state() + await self.home.get_current_state_async() self.update_all() def get_state_finished(self, future) -> None: @@ -187,8 +212,8 @@ class HomematicipHAP: retry_delay = 2 ** min(tries, 8) try: - await self.home.get_current_state() - hmip_events = await self.home.enable_events() + await self.home.get_current_state_async() + hmip_events = self.home.enable_events() tries = 0 await hmip_events except HmipConnectionError: @@ -219,7 +244,7 @@ class HomematicipHAP: self._ws_close_requested = True if self._retry_task is not None: self._retry_task.cancel() - await self.home.disable_events() + await self.home.disable_events_async() _LOGGER.debug("Closed connection to HomematicIP cloud server") await self.hass.config_entries.async_unload_platforms( self.config_entry, PLATFORMS @@ -246,17 +271,17 @@ class HomematicipHAP: name: str | None, ) -> AsyncHome: """Create a HomematicIP access point object.""" - home = AsyncHome(hass.loop, async_get_clientsession(hass)) + home = AsyncHome() home.name = name # Use the title of the config entry as title for the home. home.label = self.config_entry.title home.modelType = "HomematicIP Cloud Home" - home.set_auth_token(authtoken) try: - await home.init(hapid) - await home.get_current_state() + context = await build_context_async(hass, hapid, authtoken) + home.init_with_context(context, True, get_async_client(hass)) + await home.get_current_state_async() except HmipConnectionError as err: raise HmipcConnectionError from err home.on_update(self.async_update) diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index ad946809fd4..338599b9a14 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -4,18 +4,18 @@ from __future__ import annotations from typing import Any -from homematicip.aio.device import ( - AsyncBrandDimmer, - AsyncBrandSwitchMeasuring, - AsyncBrandSwitchNotificationLight, - AsyncDimmer, - AsyncDinRailDimmer3, - AsyncFullFlushDimmer, - AsyncPluggableDimmer, - AsyncWiredDimmer3, -) from homematicip.base.enums import OpticalSignalBehaviour, RGBColorState from homematicip.base.functionalChannels import NotificationLightChannel +from homematicip.device import ( + BrandDimmer, + BrandSwitchMeasuring, + BrandSwitchNotificationLight, + Dimmer, + DinRailDimmer3, + FullFlushDimmer, + PluggableDimmer, + WiredDimmer3, +) from packaging.version import Version from homeassistant.components.light import ( @@ -46,9 +46,9 @@ async def async_setup_entry( hap = hass.data[DOMAIN][config_entry.unique_id] entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: - if isinstance(device, AsyncBrandSwitchMeasuring): + if isinstance(device, BrandSwitchMeasuring): entities.append(HomematicipLightMeasuring(hap, device)) - elif isinstance(device, AsyncBrandSwitchNotificationLight): + elif isinstance(device, BrandSwitchNotificationLight): device_version = Version(device.firmwareVersion) entities.append(HomematicipLight(hap, device)) @@ -65,14 +65,14 @@ async def async_setup_entry( entity_class(hap, device, device.bottomLightChannelIndex, "Bottom") ) - elif isinstance(device, (AsyncWiredDimmer3, AsyncDinRailDimmer3)): + elif isinstance(device, (WiredDimmer3, DinRailDimmer3)): entities.extend( HomematicipMultiDimmer(hap, device, channel=channel) for channel in range(1, 4) ) elif isinstance( device, - (AsyncDimmer, AsyncPluggableDimmer, AsyncBrandDimmer, AsyncFullFlushDimmer), + (Dimmer, PluggableDimmer, BrandDimmer, FullFlushDimmer), ): entities.append(HomematicipDimmer(hap, device)) @@ -96,11 +96,11 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - await self._device.turn_on() + await self._device.turn_on_async() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" - await self._device.turn_off() + await self._device.turn_off_async() class HomematicipLightMeasuring(HomematicipLight): @@ -141,15 +141,15 @@ class HomematicipMultiDimmer(HomematicipGenericEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the dimmer on.""" if ATTR_BRIGHTNESS in kwargs: - await self._device.set_dim_level( + await self._device.set_dim_level_async( kwargs[ATTR_BRIGHTNESS] / 255.0, self._channel ) else: - await self._device.set_dim_level(1, self._channel) + await self._device.set_dim_level_async(1, self._channel) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the dimmer off.""" - await self._device.set_dim_level(0, self._channel) + await self._device.set_dim_level_async(0, self._channel) class HomematicipDimmer(HomematicipMultiDimmer, LightEntity): @@ -239,7 +239,7 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity): dim_level = brightness / 255.0 transition = kwargs.get(ATTR_TRANSITION, 0.5) - await self._device.set_rgb_dim_level_with_time( + await self._device.set_rgb_dim_level_with_time_async( channelIndex=self._channel, rgb=simple_rgb_color, dimLevel=dim_level, @@ -252,7 +252,7 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity): simple_rgb_color = self._func_channel.simpleRGBColorState transition = kwargs.get(ATTR_TRANSITION, 0.5) - await self._device.set_rgb_dim_level_with_time( + await self._device.set_rgb_dim_level_with_time_async( channelIndex=self._channel, rgb=simple_rgb_color, dimLevel=0.0, diff --git a/homeassistant/components/homematicip_cloud/lock.py b/homeassistant/components/homematicip_cloud/lock.py index a054e95a80d..04461682f8d 100644 --- a/homeassistant/components/homematicip_cloud/lock.py +++ b/homeassistant/components/homematicip_cloud/lock.py @@ -5,8 +5,8 @@ from __future__ import annotations import logging from typing import Any -from homematicip.aio.device import AsyncDoorLockDrive from homematicip.base.enums import LockState, MotorState +from homematicip.device import DoorLockDrive from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry @@ -45,7 +45,7 @@ async def async_setup_entry( async_add_entities( HomematicipDoorLockDrive(hap, device) for device in hap.home.devices - if isinstance(device, AsyncDoorLockDrive) + if isinstance(device, DoorLockDrive) ) @@ -75,17 +75,17 @@ class HomematicipDoorLockDrive(HomematicipGenericEntity, LockEntity): @handle_errors async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" - return await self._device.set_lock_state(LockState.LOCKED) + return await self._device.set_lock_state_async(LockState.LOCKED) @handle_errors async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" - return await self._device.set_lock_state(LockState.UNLOCKED) + return await self._device.set_lock_state_async(LockState.UNLOCKED) @handle_errors async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - return await self._device.set_lock_state(LockState.OPEN) + return await self._device.set_lock_state_async(LockState.OPEN) @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 414ba37709e..15bc24c110f 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.7"] + "requirements": ["homematicip==2.0.1.1"] } diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 0280f5bc7d5..ba739273788 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -5,39 +5,39 @@ from __future__ import annotations from collections.abc import Callable from typing import Any -from homematicip.aio.device import ( - AsyncBrandSwitchMeasuring, - AsyncEnergySensorsInterface, - AsyncFloorTerminalBlock6, - AsyncFloorTerminalBlock10, - AsyncFloorTerminalBlock12, - AsyncFullFlushSwitchMeasuring, - AsyncHeatingThermostat, - AsyncHeatingThermostatCompact, - AsyncHeatingThermostatEvo, - AsyncHomeControlAccessPoint, - AsyncLightSensor, - AsyncMotionDetectorIndoor, - AsyncMotionDetectorOutdoor, - AsyncMotionDetectorPushButton, - AsyncPassageDetector, - AsyncPlugableSwitchMeasuring, - AsyncPresenceDetectorIndoor, - AsyncRoomControlDeviceAnalog, - AsyncTemperatureDifferenceSensor2, - AsyncTemperatureHumiditySensorDisplay, - AsyncTemperatureHumiditySensorOutdoor, - AsyncTemperatureHumiditySensorWithoutDisplay, - AsyncWeatherSensor, - AsyncWeatherSensorPlus, - AsyncWeatherSensorPro, - AsyncWiredFloorTerminalBlock12, -) from homematicip.base.enums import FunctionalChannelType, ValveState from homematicip.base.functionalChannels import ( FloorTerminalBlockMechanicChannel, FunctionalChannel, ) +from homematicip.device import ( + BrandSwitchMeasuring, + EnergySensorsInterface, + FloorTerminalBlock6, + FloorTerminalBlock10, + FloorTerminalBlock12, + FullFlushSwitchMeasuring, + HeatingThermostat, + HeatingThermostatCompact, + HeatingThermostatEvo, + HomeControlAccessPoint, + LightSensor, + MotionDetectorIndoor, + MotionDetectorOutdoor, + MotionDetectorPushButton, + PassageDetector, + PlugableSwitchMeasuring, + PresenceDetectorIndoor, + RoomControlDeviceAnalog, + TemperatureDifferenceSensor2, + TemperatureHumiditySensorDisplay, + TemperatureHumiditySensorOutdoor, + TemperatureHumiditySensorWithoutDisplay, + WeatherSensor, + WeatherSensorPlus, + WeatherSensorPro, + WiredFloorTerminalBlock12, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -46,6 +46,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, LIGHT_LUX, PERCENTAGE, UnitOfEnergy, @@ -102,14 +103,14 @@ async def async_setup_entry( hap = hass.data[DOMAIN][config_entry.unique_id] entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: - if isinstance(device, AsyncHomeControlAccessPoint): + if isinstance(device, HomeControlAccessPoint): entities.append(HomematicipAccesspointDutyCycle(hap, device)) if isinstance( device, ( - AsyncHeatingThermostat, - AsyncHeatingThermostatCompact, - AsyncHeatingThermostatEvo, + HeatingThermostat, + HeatingThermostatCompact, + HeatingThermostatEvo, ), ): entities.append(HomematicipHeatingThermostat(hap, device)) @@ -117,55 +118,54 @@ async def async_setup_entry( if isinstance( device, ( - AsyncTemperatureHumiditySensorDisplay, - AsyncTemperatureHumiditySensorWithoutDisplay, - AsyncTemperatureHumiditySensorOutdoor, - AsyncWeatherSensor, - AsyncWeatherSensorPlus, - AsyncWeatherSensorPro, + TemperatureHumiditySensorDisplay, + TemperatureHumiditySensorWithoutDisplay, + TemperatureHumiditySensorOutdoor, + WeatherSensor, + WeatherSensorPlus, + WeatherSensorPro, ), ): entities.append(HomematicipTemperatureSensor(hap, device)) entities.append(HomematicipHumiditySensor(hap, device)) - elif isinstance(device, (AsyncRoomControlDeviceAnalog,)): + entities.append(HomematicipAbsoluteHumiditySensor(hap, device)) + elif isinstance(device, (RoomControlDeviceAnalog,)): entities.append(HomematicipTemperatureSensor(hap, device)) if isinstance( device, ( - AsyncLightSensor, - AsyncMotionDetectorIndoor, - AsyncMotionDetectorOutdoor, - AsyncMotionDetectorPushButton, - AsyncPresenceDetectorIndoor, - AsyncWeatherSensor, - AsyncWeatherSensorPlus, - AsyncWeatherSensorPro, + LightSensor, + MotionDetectorIndoor, + MotionDetectorOutdoor, + MotionDetectorPushButton, + PresenceDetectorIndoor, + WeatherSensor, + WeatherSensorPlus, + WeatherSensorPro, ), ): entities.append(HomematicipIlluminanceSensor(hap, device)) if isinstance( device, ( - AsyncPlugableSwitchMeasuring, - AsyncBrandSwitchMeasuring, - AsyncFullFlushSwitchMeasuring, + PlugableSwitchMeasuring, + BrandSwitchMeasuring, + FullFlushSwitchMeasuring, ), ): entities.append(HomematicipPowerSensor(hap, device)) entities.append(HomematicipEnergySensor(hap, device)) - if isinstance( - device, (AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) - ): + if isinstance(device, (WeatherSensor, WeatherSensorPlus, WeatherSensorPro)): entities.append(HomematicipWindspeedSensor(hap, device)) - if isinstance(device, (AsyncWeatherSensorPlus, AsyncWeatherSensorPro)): + if isinstance(device, (WeatherSensorPlus, WeatherSensorPro)): entities.append(HomematicipTodayRainSensor(hap, device)) - if isinstance(device, AsyncPassageDetector): + if isinstance(device, PassageDetector): entities.append(HomematicipPassageDetectorDeltaCounter(hap, device)) - if isinstance(device, AsyncTemperatureDifferenceSensor2): + if isinstance(device, TemperatureDifferenceSensor2): entities.append(HomematicpTemperatureExternalSensorCh1(hap, device)) entities.append(HomematicpTemperatureExternalSensorCh2(hap, device)) entities.append(HomematicpTemperatureExternalSensorDelta(hap, device)) - if isinstance(device, AsyncEnergySensorsInterface): + if isinstance(device, EnergySensorsInterface): for ch in get_channels_from_device( device, FunctionalChannelType.ENERGY_SENSORS_INTERFACE_CHANNEL ): @@ -194,10 +194,10 @@ async def async_setup_entry( if isinstance( device, ( - AsyncFloorTerminalBlock6, - AsyncFloorTerminalBlock10, - AsyncFloorTerminalBlock12, - AsyncWiredFloorTerminalBlock12, + FloorTerminalBlock6, + FloorTerminalBlock10, + FloorTerminalBlock12, + WiredFloorTerminalBlock12, ), ): entities.extend( @@ -350,6 +350,35 @@ class HomematicipTemperatureSensor(HomematicipGenericEntity, SensorEntity): return state_attr +class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity): + """Representation of the HomematicIP absolute humidity sensor.""" + + _attr_native_unit_of_measurement = CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the thermometer device.""" + super().__init__(hap, device, post="Absolute Humidity") + + @property + def native_value(self) -> int | None: + """Return the state.""" + if self.functional_channel is None: + return None + + value = self.functional_channel.vaporAmount + + # Handle case where value might be None + if ( + self.functional_channel.vaporAmount is None + or self.functional_channel.vaporAmount == "" + ): + return None + + # Convert from g/m³ to mg/m³ + return int(float(value) * 1000) + + class HomematicipIlluminanceSensor(HomematicipGenericEntity, SensorEntity): """Representation of the HomematicIP Illuminance sensor.""" diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index 7a4dfd4916f..4518c7736eb 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -5,10 +5,10 @@ from __future__ import annotations import logging from pathlib import Path -from homematicip.aio.device import AsyncSwitchMeasuring -from homematicip.aio.group import AsyncHeatingGroup -from homematicip.aio.home import AsyncHome +from homematicip.async_home import AsyncHome from homematicip.base.helpers import handle_config +from homematicip.device import SwitchMeasuring +from homematicip.group import HeatingGroup import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE @@ -233,10 +233,10 @@ async def _async_activate_eco_mode_with_duration( if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.activate_absence_with_duration(duration) + await home.activate_absence_with_duration_async(duration) else: for hap in hass.data[DOMAIN].values(): - await hap.home.activate_absence_with_duration(duration) + await hap.home.activate_absence_with_duration_async(duration) async def _async_activate_eco_mode_with_period( @@ -247,10 +247,10 @@ async def _async_activate_eco_mode_with_period( if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.activate_absence_with_period(endtime) + await home.activate_absence_with_period_async(endtime) else: for hap in hass.data[DOMAIN].values(): - await hap.home.activate_absence_with_period(endtime) + await hap.home.activate_absence_with_period_async(endtime) async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) -> None: @@ -260,30 +260,30 @@ async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) -> if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.activate_vacation(endtime, temperature) + await home.activate_vacation_async(endtime, temperature) else: for hap in hass.data[DOMAIN].values(): - await hap.home.activate_vacation(endtime, temperature) + await hap.home.activate_vacation_async(endtime, temperature) async def _async_deactivate_eco_mode(hass: HomeAssistant, service: ServiceCall) -> None: """Service to deactivate eco mode.""" if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.deactivate_absence() + await home.deactivate_absence_async() else: for hap in hass.data[DOMAIN].values(): - await hap.home.deactivate_absence() + await hap.home.deactivate_absence_async() async def _async_deactivate_vacation(hass: HomeAssistant, service: ServiceCall) -> None: """Service to deactivate vacation.""" if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.deactivate_vacation() + await home.deactivate_vacation_async() else: for hap in hass.data[DOMAIN].values(): - await hap.home.deactivate_vacation() + await hap.home.deactivate_vacation_async() async def _set_active_climate_profile( @@ -297,12 +297,12 @@ async def _set_active_climate_profile( if entity_id_list != "all": for entity_id in entity_id_list: group = hap.hmip_device_by_entity_id.get(entity_id) - if group and isinstance(group, AsyncHeatingGroup): - await group.set_active_profile(climate_profile_index) + if group and isinstance(group, HeatingGroup): + await group.set_active_profile_async(climate_profile_index) else: for group in hap.home.groups: - if isinstance(group, AsyncHeatingGroup): - await group.set_active_profile(climate_profile_index) + if isinstance(group, HeatingGroup): + await group.set_active_profile_async(climate_profile_index) async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> None: @@ -323,7 +323,7 @@ async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> N path = Path(config_path) config_file = path / file_name - json_state = await hap.home.download_configuration() + json_state = await hap.home.download_configuration_async() json_state = handle_config(json_state, anonymize) config_file.write_text(json_state, encoding="utf8") @@ -337,12 +337,12 @@ async def _async_reset_energy_counter(hass: HomeAssistant, service: ServiceCall) if entity_id_list != "all": for entity_id in entity_id_list: device = hap.hmip_device_by_entity_id.get(entity_id) - if device and isinstance(device, AsyncSwitchMeasuring): - await device.reset_energy_counter() + if device and isinstance(device, SwitchMeasuring): + await device.reset_energy_counter_async() else: for device in hap.home.devices: - if isinstance(device, AsyncSwitchMeasuring): - await device.reset_energy_counter() + if isinstance(device, SwitchMeasuring): + await device.reset_energy_counter_async() async def _async_set_home_cooling_mode(hass: HomeAssistant, service: ServiceCall): @@ -351,10 +351,10 @@ async def _async_set_home_cooling_mode(hass: HomeAssistant, service: ServiceCall if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.set_cooling(cooling) + await home.set_cooling_async(cooling) else: for hap in hass.data[DOMAIN].values(): - await hap.home.set_cooling(cooling) + await hap.home.set_cooling_async(cooling) def _get_home(hass: HomeAssistant, hapid: str) -> AsyncHome | None: diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index a9aa1c664d7..2de02fb22a5 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -4,23 +4,23 @@ from __future__ import annotations from typing import Any -from homematicip.aio.device import ( - AsyncBrandSwitch2, - AsyncBrandSwitchMeasuring, - AsyncDinRailSwitch, - AsyncDinRailSwitch4, - AsyncFullFlushInputSwitch, - AsyncFullFlushSwitchMeasuring, - AsyncHeatingSwitch2, - AsyncMultiIOBox, - AsyncOpenCollector8Module, - AsyncPlugableSwitch, - AsyncPlugableSwitchMeasuring, - AsyncPrintedCircuitBoardSwitch2, - AsyncPrintedCircuitBoardSwitchBattery, - AsyncWiredSwitch8, +from homematicip.device import ( + BrandSwitch2, + BrandSwitchMeasuring, + DinRailSwitch, + DinRailSwitch4, + FullFlushInputSwitch, + FullFlushSwitchMeasuring, + HeatingSwitch2, + MultiIOBox, + OpenCollector8Module, + PlugableSwitch, + PlugableSwitchMeasuring, + PrintedCircuitBoardSwitch2, + PrintedCircuitBoardSwitchBattery, + WiredSwitch8, ) -from homematicip.aio.group import AsyncExtendedLinkedSwitchingGroup, AsyncSwitchingGroup +from homematicip.group import ExtendedLinkedSwitchingGroup, SwitchingGroup from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -42,26 +42,24 @@ async def async_setup_entry( entities: list[HomematicipGenericEntity] = [ HomematicipGroupSwitch(hap, group) for group in hap.home.groups - if isinstance(group, (AsyncExtendedLinkedSwitchingGroup, AsyncSwitchingGroup)) + if isinstance(group, (ExtendedLinkedSwitchingGroup, SwitchingGroup)) ] for device in hap.home.devices: - if isinstance(device, AsyncBrandSwitchMeasuring): + if isinstance(device, BrandSwitchMeasuring): # BrandSwitchMeasuring inherits PlugableSwitchMeasuring # This entity is implemented in the light platform and will # not be added in the switch platform pass - elif isinstance( - device, (AsyncPlugableSwitchMeasuring, AsyncFullFlushSwitchMeasuring) - ): + elif isinstance(device, (PlugableSwitchMeasuring, FullFlushSwitchMeasuring)): entities.append(HomematicipSwitchMeasuring(hap, device)) - elif isinstance(device, AsyncWiredSwitch8): + elif isinstance(device, WiredSwitch8): entities.extend( HomematicipMultiSwitch(hap, device, channel=channel) for channel in range(1, 9) ) - elif isinstance(device, AsyncDinRailSwitch): + elif isinstance(device, DinRailSwitch): entities.append(HomematicipMultiSwitch(hap, device, channel=1)) - elif isinstance(device, AsyncDinRailSwitch4): + elif isinstance(device, DinRailSwitch4): entities.extend( HomematicipMultiSwitch(hap, device, channel=channel) for channel in range(1, 5) @@ -69,13 +67,13 @@ async def async_setup_entry( elif isinstance( device, ( - AsyncPlugableSwitch, - AsyncPrintedCircuitBoardSwitchBattery, - AsyncFullFlushInputSwitch, + PlugableSwitch, + PrintedCircuitBoardSwitchBattery, + FullFlushInputSwitch, ), ): entities.append(HomematicipSwitch(hap, device)) - elif isinstance(device, AsyncOpenCollector8Module): + elif isinstance(device, OpenCollector8Module): entities.extend( HomematicipMultiSwitch(hap, device, channel=channel) for channel in range(1, 9) @@ -83,10 +81,10 @@ async def async_setup_entry( elif isinstance( device, ( - AsyncBrandSwitch2, - AsyncPrintedCircuitBoardSwitch2, - AsyncHeatingSwitch2, - AsyncMultiIOBox, + BrandSwitch2, + PrintedCircuitBoardSwitch2, + HeatingSwitch2, + MultiIOBox, ), ): entities.extend( @@ -119,11 +117,11 @@ class HomematicipMultiSwitch(HomematicipGenericEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self._device.turn_on(self._channel) + await self._device.turn_on_async(self._channel) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self._device.turn_off(self._channel) + await self._device.turn_off_async(self._channel) class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity): @@ -168,11 +166,11 @@ class HomematicipGroupSwitch(HomematicipGenericEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the group on.""" - await self._device.turn_on() + await self._device.turn_on_async() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the group off.""" - await self._device.turn_off() + await self._device.turn_off_async() class HomematicipSwitchMeasuring(HomematicipSwitch): diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index 1125c73f8d4..78e86ec652c 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -2,12 +2,8 @@ from __future__ import annotations -from homematicip.aio.device import ( - AsyncWeatherSensor, - AsyncWeatherSensorPlus, - AsyncWeatherSensorPro, -) from homematicip.base.enums import WeatherCondition +from homematicip.device import WeatherSensor, WeatherSensorPlus, WeatherSensorPro from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, @@ -59,9 +55,9 @@ async def async_setup_entry( hap = hass.data[DOMAIN][config_entry.unique_id] entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: - if isinstance(device, AsyncWeatherSensorPro): + if isinstance(device, WeatherSensorPro): entities.append(HomematicipWeatherSensorPro(hap, device)) - elif isinstance(device, (AsyncWeatherSensor, AsyncWeatherSensorPlus)): + elif isinstance(device, (WeatherSensor, WeatherSensorPlus)): entities.append(HomematicipWeatherSensor(hap, device)) entities.append(HomematicipHomeWeather(hap)) diff --git a/homeassistant/components/homeworks/strings.json b/homeassistant/components/homeworks/strings.json index 1a144615e89..3ec4945957b 100644 --- a/homeassistant/components/homeworks/strings.json +++ b/homeassistant/components/homeworks/strings.json @@ -57,7 +57,7 @@ }, "exceptions": { "invalid_controller_id": { - "message": "Invalid controller_id \"{controller_id}\", expected one of \"{controller_ids}\"" + "message": "Invalid controller ID \"{controller_id}\", expected one of \"{controller_ids}\"" } }, "options": { diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index 2538e7101a1..67295ec5802 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Please enter the credentials used to log into mytotalconnectcomfort.com.", + "description": "Please enter the credentials used to log in to mytotalconnectcomfort.com.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -55,7 +55,7 @@ "preset_mode": { "state": { "hold": "Hold", - "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "away": "[%key:common::state::not_home%]", "none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::none%]" } } diff --git a/homeassistant/components/http/headers.py b/homeassistant/components/http/headers.py index ebc0594e15a..fdb325c7b74 100644 --- a/homeassistant/components/http/headers.py +++ b/homeassistant/components/http/headers.py @@ -3,25 +3,34 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from typing import Final +from aiohttp import hdrs from aiohttp.web import Application, Request, StreamResponse, middleware from aiohttp.web_exceptions import HTTPException +from multidict import CIMultiDict, istr from homeassistant.core import callback +REFERRER_POLICY: Final[istr] = istr("Referrer-Policy") +X_CONTENT_TYPE_OPTIONS: Final[istr] = istr("X-Content-Type-Options") +X_FRAME_OPTIONS: Final[istr] = istr("X-Frame-Options") + @callback def setup_headers(app: Application, use_x_frame_options: bool) -> None: """Create headers middleware for the app.""" - added_headers = { - "Referrer-Policy": "no-referrer", - "X-Content-Type-Options": "nosniff", - "Server": "", # Empty server header, to prevent aiohttp of setting one. - } + added_headers = CIMultiDict( + { + REFERRER_POLICY: "no-referrer", + X_CONTENT_TYPE_OPTIONS: "nosniff", + hdrs.SERVER: "", # Empty server header, to prevent aiohttp of setting one. + } + ) if use_x_frame_options: - added_headers["X-Frame-Options"] = "SAMEORIGIN" + added_headers[X_FRAME_OPTIONS] = "SAMEORIGIN" @middleware async def headers_middleware( diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index a5a60d8406d..be9d02e45fd 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -88,7 +88,7 @@ from .utils import get_device_macs, non_verifying_requests_session _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=10) +SCAN_INTERVAL = timedelta(seconds=30) NOTIFY_SCHEMA = vol.Any( None, diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index c3434dd0b64..41f4638b713 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -9,6 +9,7 @@ from huawei_lte_api.enums.cradle import ConnectionStatusEnum from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -104,6 +105,7 @@ class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor): _attr_translation_key = "mobile_connection" _attr_entity_registry_enabled_default = True + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY key = KEY_MONITORING_STATUS item = "ConnectionStatus" @@ -140,6 +142,8 @@ class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor): class HuaweiLteBaseWifiStatusBinarySensor(HuaweiLteBaseBinarySensor): """Huawei LTE WiFi status binary sensor base class.""" + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + @property def is_on(self) -> bool: """Return whether the binary sensor is on.""" diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 4ca9e7531e3..88167fab4b9 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -40,6 +40,7 @@ from homeassistant.core import callback from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, ATTR_UPNP_PRESENTATION_URL, ATTR_UPNP_SERIAL, ATTR_UPNP_UDN, @@ -276,11 +277,12 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): if TYPE_CHECKING: assert discovery_info.ssdp_location url = url_normalize( - discovery_info.upnp.get( - ATTR_UPNP_PRESENTATION_URL, - f"http://{urlparse(discovery_info.ssdp_location).hostname}/", - ) + discovery_info.upnp.get(ATTR_UPNP_PRESENTATION_URL) + or f"http://{urlparse(discovery_info.ssdp_location).hostname}/" ) + if TYPE_CHECKING: + # url_normalize only returns None if passed None, and we don't do that + assert url is not None unique_id = discovery_info.upnp.get( ATTR_UPNP_SERIAL, discovery_info.upnp[ATTR_UPNP_UDN] @@ -308,8 +310,11 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): self.context.update( { "title_placeholders": { - CONF_NAME: discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) - or "Huawei LTE" + CONF_NAME: ( + discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) + or discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) + or "Huawei LTE" + ) } } ) diff --git a/homeassistant/components/huawei_lte/entity.py b/homeassistant/components/huawei_lte/entity.py index 99d7ca112c4..b69d2e79fb6 100644 --- a/homeassistant/components/huawei_lte/entity.py +++ b/homeassistant/components/huawei_lte/entity.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from datetime import timedelta from homeassistant.helpers.device_registry import DeviceInfo @@ -25,7 +24,6 @@ class HuaweiLteBaseEntity(Entity): def __init__(self, router: Router) -> None: """Initialize.""" self.router = router - self._unsub_handlers: list[Callable] = [] @property def _device_unique_id(self) -> str: @@ -48,7 +46,7 @@ class HuaweiLteBaseEntity(Entity): async def async_added_to_hass(self) -> None: """Connect to update signals.""" - self._unsub_handlers.append( + self.async_on_remove( async_dispatcher_connect(self.hass, UPDATE_SIGNAL, self._async_maybe_update) ) @@ -57,12 +55,6 @@ class HuaweiLteBaseEntity(Entity): if config_entry_unique_id == self.router.config_entry.unique_id: self.async_schedule_update_ha_state(True) - async def async_will_remove_from_hass(self) -> None: - """Invoke unsubscription handlers.""" - for unsub in self._unsub_handlers: - unsub() - self._unsub_handlers.clear() - class HuaweiLteBaseEntityWithDevice(HuaweiLteBaseEntity): """Base entity with device info.""" diff --git a/homeassistant/components/huawei_lte/icons.json b/homeassistant/components/huawei_lte/icons.json index a338cc65ed4..22eb345eba5 100644 --- a/homeassistant/components/huawei_lte/icons.json +++ b/homeassistant/components/huawei_lte/icons.json @@ -34,7 +34,7 @@ }, "select": { "preferred_network_mode": { - "default": "mdi:transmission-tower" + "default": "mdi:antenna" } }, "switch": { diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 6720d6718ef..c2e945e9c49 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -7,9 +7,9 @@ "iot_class": "local_polling", "loggers": ["huawei_lte_api.Session"], "requirements": [ - "huawei-lte-api==1.10.0", + "huawei-lte-api==1.11.0", "stringcase==1.2.0", - "url-normalize==1.4.3" + "url-normalize==2.2.1" ], "ssdp": [ { diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 3543433ca45..e9270dfd6ff 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -181,7 +181,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "cell_id": HuaweiSensorEntityDescription( key="cell_id", translation_key="cell_id", - icon="mdi:transmission-tower", + icon="mdi:antenna", entity_category=EntityCategory.DIAGNOSTIC, ), "cqi0": HuaweiSensorEntityDescription( @@ -230,6 +230,12 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "enodeb_id": HuaweiSensorEntityDescription( key="enodeb_id", translation_key="enodeb_id", + icon="mdi:antenna", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "ims": HuaweiSensorEntityDescription( + key="ims", + translation_key="ims", entity_category=EntityCategory.DIAGNOSTIC, ), "lac": HuaweiSensorEntityDescription( @@ -270,6 +276,12 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), entity_category=EntityCategory.DIAGNOSTIC, ), + "nei_cellid": HuaweiSensorEntityDescription( + key="nei_cellid", + translation_key="nei_cellid", + icon="mdi:antenna", + entity_category=EntityCategory.DIAGNOSTIC, + ), "nrbler": HuaweiSensorEntityDescription( key="nrbler", translation_key="nrbler", @@ -364,7 +376,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "pci": HuaweiSensorEntityDescription( key="pci", translation_key="pci", - icon="mdi:transmission-tower", + icon="mdi:antenna", entity_category=EntityCategory.DIAGNOSTIC, ), "plmn": HuaweiSensorEntityDescription( @@ -422,6 +434,17 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=True, ), + "rxlev": HuaweiSensorEntityDescription( + key="rxlev", + translation_key="rxlev", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "sc": HuaweiSensorEntityDescription( + key="sc", + translation_key="sc", + entity_category=EntityCategory.DIAGNOSTIC, + ), "sinr": HuaweiSensorEntityDescription( key="sinr", translation_key="sinr", @@ -479,6 +502,12 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { device_class=SensorDeviceClass.FREQUENCY, entity_category=EntityCategory.DIAGNOSTIC, ), + "wdlfreq": HuaweiSensorEntityDescription( + key="wdlfreq", + translation_key="wdlfreq", + device_class=SensorDeviceClass.FREQUENCY, + entity_category=EntityCategory.DIAGNOSTIC, + ), } ), # @@ -542,6 +571,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "BatteryPercent": HuaweiSensorEntityDescription( key="BatteryPercent", + translation_key="battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 879c7215562..50879c9e166 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -26,6 +26,10 @@ "data": { "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::huawei_lte::config::step::user::data_description::password%]", + "username": "[%key:component::huawei_lte::config::step::user::data_description::username%]" } }, "user": { @@ -35,6 +39,12 @@ "username": "[%key:common::config_flow::data::username%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, + "data_description": { + "password": "Password for accessing the router's API. Typically, the same as the one used for the router's web interface.", + "url": "Base URL to the API of the router. Typically, something like `http://192.168.X.1`. This is the beginning of the location shown in a browser when accessing the router's web interface.", + "username": "Username for accessing the router's API. Typically, the same as the one used for the router's web interface. Usually, either `admin`, or left empty (recommended if that works).", + "verify_ssl": "Whether to verify the SSL certificate of the router when accessing it. Applicable only if the router is accessed via HTTPS." + }, "description": "Enter device access details.", "title": "Configure Huawei LTE" } @@ -48,6 +58,12 @@ "recipient": "SMS notification recipients", "track_wired_clients": "Track wired network clients", "unauthenticated_mode": "Unauthenticated mode (change requires reload)" + }, + "data_description": { + "name": "Used to distinguish between notification services in case there are multiple Huawei LTE devices configured. Changes to this option value take effect after Home Assistant restart.", + "recipient": "Comma separated list of default recipient SMS phone numbers for the notification service, used in case the notification sender does not specify any.", + "track_wired_clients": "Whether the device tracker entities track also clients attached to the router's wired Ethernet network, in addition to wireless clients.", + "unauthenticated_mode": "Whether to run in unauthenticated mode. Unauthenticated mode provides a limited set of features, but may help in case there are problems accessing the router's web interface from a browser while the integration is active. Changes to this option value take effect after integration reload." } } } @@ -116,6 +132,9 @@ "enodeb_id": { "name": "eNodeB ID" }, + "ims": { + "name": "IMS" + }, "lac": { "name": "LAC" }, @@ -125,6 +144,12 @@ "lte_uplink_frequency": { "name": "LTE uplink frequency" }, + "mode": { + "name": "Mode" + }, + "nei_cellid": { + "name": "Neighbor cell ID" + }, "nrbler": { "name": "5G block error rate" }, @@ -188,6 +213,12 @@ "rssi": { "name": "RSSI" }, + "rxlev": { + "name": "Received signal level" + }, + "sc": { + "name": "Scrambling code" + }, "sinr": { "name": "SINR" }, @@ -212,6 +243,9 @@ "uplink_frequency": { "name": "Uplink frequency" }, + "wdlfreq": { + "name": "WCDMA downlink frequency" + }, "sms_unread": { "name": "SMS unread" }, @@ -224,6 +258,9 @@ "current_month_upload": { "name": "Current month upload" }, + "battery": { + "name": "Battery" + }, "wifi_clients_connected": { "name": "Wi-Fi clients connected" }, @@ -272,8 +309,8 @@ "operator_search_mode": { "name": "Operator search mode", "state": { - "0": "Auto", - "1": "Manual" + "0": "[%key:common::state::auto%]", + "1": "[%key:common::state::manual%]" } }, "preferred_network_mode": { diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 3326dd1043f..44a6eb72acc 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -57,7 +57,7 @@ "3": "[%key:component::hue::device_automation::trigger_subtype::button_3%]", "4": "[%key:component::hue::device_automation::trigger_subtype::button_4%]", "clock_wise": "Rotation clockwise", - "counter_clock_wise": "Rotation counter-clockwise" + "counter_clock_wise": "Rotation counterclockwise" }, "trigger_type": { "remote_button_long_release": "\"{subtype}\" released after long press", @@ -96,7 +96,7 @@ "event_type": { "state": { "clock_wise": "Clockwise", - "counter_clock_wise": "Counter clockwise" + "counter_clock_wise": "Counterclockwise" } } } diff --git a/homeassistant/components/huisbaasje/strings.json b/homeassistant/components/huisbaasje/strings.json index de112f7519f..3958e6a8903 100644 --- a/homeassistant/components/huisbaasje/strings.json +++ b/homeassistant/components/huisbaasje/strings.json @@ -26,25 +26,25 @@ "name": "Current power in peak" }, "current_power_off_peak": { - "name": "Current power in off peak" + "name": "Current power in off-peak" }, "current_power_out_peak": { "name": "Current power out peak" }, "current_power_out_off_peak": { - "name": "Current power out off peak" + "name": "Current power out off-peak" }, "energy_consumption_peak_today": { "name": "Energy consumption peak today" }, "energy_consumption_off_peak_today": { - "name": "Energy consumption off peak today" + "name": "Energy consumption off-peak today" }, "energy_production_peak_today": { "name": "Energy production peak today" }, "energy_production_off_peak_today": { - "name": "Energy production off peak today" + "name": "Energy production off-peak today" }, "energy_today": { "name": "Energy today" diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 436f7df8312..6c0c691c705 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -62,15 +62,15 @@ "mode": { "name": "Mode", "state": { - "normal": "Normal", - "eco": "Eco", - "away": "Away", + "normal": "[%key:common::state::normal%]", + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]", + "auto": "[%key:common::state::auto%]", + "baby": "Baby", "boost": "Boost", "comfort": "Comfort", - "home": "[%key:common::state::home%]", - "sleep": "Sleep", - "auto": "Auto", - "baby": "Baby" + "eco": "Eco", + "sleep": "Sleep" } } } diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index c23ca508916..dc653d8ce80 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -61,6 +61,15 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): self._zones_last_update: dict[str, set[str]] = {} self._areas_last_update: dict[str, set[int]] = {} + def _async_add_remove_devices_and_entities(self, data: MowerDictionary) -> None: + """Add/remove devices and dynamic entities, when amount of devices changed.""" + 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) + if data[mower_id].capabilities.work_areas: + self._async_add_remove_work_areas(data) + async def _async_update_data(self) -> MowerDictionary: """Subscribe for websocket and poll data from the API.""" if not self.ws_connected: @@ -73,20 +82,14 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): raise UpdateFailed(err) from err except AuthError 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) + self._async_add_remove_devices_and_entities(data) return data @callback def callback(self, ws_data: MowerDictionary) -> None: """Process websocket callbacks and write them to the DataUpdateCoordinator.""" self.async_set_updated_data(ws_data) + self._async_add_remove_devices_and_entities(ws_data) async def client_listen( self, diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index ee6007f089b..9ae214524a7 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -110,10 +110,10 @@ class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity): mower_attributes = self.mower_attributes if mower_attributes.mower.state in PAUSED_STATES: return LawnMowerActivity.PAUSED - if mower_attributes.mower.activity in MOWING_ACTIVITIES: + if mower_attributes.mower.state in MowerStates.IN_OPERATION: + if mower_attributes.mower.activity == MowerActivities.GOING_HOME: + return LawnMowerActivity.RETURNING return LawnMowerActivity.MOWING - if mower_attributes.mower.activity == MowerActivities.GOING_HOME: - return LawnMowerActivity.RETURNING if (mower_attributes.mower.state == "RESTRICTED") or ( mower_attributes.mower.activity in DOCKED_ACTIVITIES ): diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 7f728148be3..705975bb966 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==2025.3.2"] + "requirements": ["aioautomower==2025.5.1"] } diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py index 9ed00113d4b..4a57c48e66f 100644 --- a/homeassistant/components/husqvarna_automower/number.py +++ b/homeassistant/components/husqvarna_automower/number.py @@ -44,8 +44,8 @@ async def async_set_work_area_cutting_height( ) -> None: """Set cutting height for work area.""" await coordinator.api.commands.workarea_settings( - mower_id, work_area_id, cutting_height=int(cheight) - ) + mower_id, work_area_id + ).cutting_height(cutting_height=int(cheight)) async def async_set_cutting_height( diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index d7a83c82185..5ad8ad91b48 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -40,8 +40,7 @@ PARALLEL_UPDATES = 0 ATTR_WORK_AREA_ID_ASSIGNMENT = "work_area_id_assignment" -ERROR_KEY_LIST = [ - "no_error", +ERROR_KEYS = [ "alarm_mower_in_motion", "alarm_mower_lifted", "alarm_mower_stopped", @@ -50,13 +49,11 @@ ERROR_KEY_LIST = [ "alarm_outside_geofence", "angular_sensor_problem", "battery_problem", - "battery_problem", "battery_restriction_due_to_ambient_temperature", "can_error", "charging_current_too_high", "charging_station_blocked", "charging_system_problem", - "charging_system_problem", "collision_sensor_defect", "collision_sensor_error", "collision_sensor_problem_front", @@ -67,24 +64,18 @@ ERROR_KEY_LIST = [ "connection_changed", "connection_not_changed", "connectivity_problem", - "connectivity_problem", - "connectivity_problem", - "connectivity_problem", - "connectivity_problem", - "connectivity_problem", "connectivity_settings_restored", "cutting_drive_motor_1_defect", "cutting_drive_motor_2_defect", "cutting_drive_motor_3_defect", "cutting_height_blocked", - "cutting_height_problem", "cutting_height_problem_curr", "cutting_height_problem_dir", "cutting_height_problem_drive", + "cutting_height_problem", "cutting_motor_problem", "cutting_stopped_slope_too_steep", "cutting_system_blocked", - "cutting_system_blocked", "cutting_system_imbalance_warning", "cutting_system_major_imbalance", "destination_not_reachable", @@ -92,13 +83,9 @@ ERROR_KEY_LIST = [ "docking_sensor_defect", "electronic_problem", "empty_battery", - MowerStates.ERROR.lower(), - MowerStates.ERROR_AT_POWER_UP.lower(), - MowerStates.FATAL_ERROR.lower(), "folding_cutting_deck_sensor_defect", "folding_sensor_activated", "geofence_problem", - "geofence_problem", "gps_navigation_problem", "guide_1_not_found", "guide_2_not_found", @@ -116,7 +103,6 @@ ERROR_KEY_LIST = [ "lift_sensor_defect", "lifted", "limited_cutting_height_range", - "limited_cutting_height_range", "loop_sensor_defect", "loop_sensor_problem_front", "loop_sensor_problem_left", @@ -129,6 +115,7 @@ ERROR_KEY_LIST = [ "no_accurate_position_from_satellites", "no_confirmed_position", "no_drive", + "no_error", "no_loop_signal", "no_power_in_charging_station", "no_response_from_charger", @@ -139,9 +126,6 @@ ERROR_KEY_LIST = [ "safety_function_faulty", "settings_restored", "sim_card_locked", - "sim_card_locked", - "sim_card_locked", - "sim_card_locked", "sim_card_not_found", "sim_card_requires_pin", "slipped_mower_has_slipped_situation_not_solved_with_moving_pattern", @@ -151,13 +135,6 @@ ERROR_KEY_LIST = [ "stuck_in_charging_station", "switch_cord_problem", "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", "tilt_sensor_problem", "too_high_discharge_current", "too_high_internal_current", @@ -189,11 +166,19 @@ ERROR_KEY_LIST = [ "zone_generator_problem", ] -ERROR_STATES = { - MowerStates.ERROR, +ERROR_STATES = [ MowerStates.ERROR_AT_POWER_UP, + MowerStates.ERROR, MowerStates.FATAL_ERROR, -} + MowerStates.OFF, + MowerStates.STOPPED, + MowerStates.WAIT_POWER_UP, + MowerStates.WAIT_UPDATING, +] + +ERROR_KEY_LIST = list( + dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES]) +) RESTRICTED_REASONS: list = [ RestrictedReasons.ALL_WORK_AREAS_COMPLETED, @@ -292,6 +277,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( AutomowerSensorEntityDescription( key="cutting_blade_usage_time", translation_key="cutting_blade_usage_time", + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -302,6 +288,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( AutomowerSensorEntityDescription( key="downtime", translation_key="downtime", + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.DURATION, entity_registry_enabled_default=False, @@ -386,6 +373,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( AutomowerSensorEntityDescription( key="uptime", translation_key="uptime", + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.DURATION, entity_registry_enabled_default=False, diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 35ce342867f..015d322c481 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -106,10 +106,10 @@ "cutting_drive_motor_2_defect": "Cutting drive motor 2 defect", "cutting_drive_motor_3_defect": "Cutting drive motor 3 defect", "cutting_height_blocked": "Cutting height blocked", - "cutting_height_problem": "Cutting height problem", "cutting_height_problem_curr": "Cutting height problem, curr", "cutting_height_problem_dir": "Cutting height problem, dir", "cutting_height_problem_drive": "Cutting height problem, drive", + "cutting_height_problem": "Cutting height problem", "cutting_motor_problem": "Cutting motor problem", "cutting_stopped_slope_too_steep": "Cutting stopped - slope too steep", "cutting_system_blocked": "Cutting system blocked", @@ -120,8 +120,8 @@ "docking_sensor_defect": "Docking sensor defect", "electronic_problem": "Electronic problem", "empty_battery": "Empty battery", - "error": "Error", "error_at_power_up": "Error at power up", + "error": "[%key:common::state::error%]", "fatal_error": "Fatal error", "folding_cutting_deck_sensor_defect": "Folding cutting deck sensor defect", "folding_sensor_activated": "Folding sensor activated", @@ -159,6 +159,7 @@ "no_loop_signal": "No loop signal", "no_power_in_charging_station": "No power in charging station", "no_response_from_charger": "No response from charger", + "off": "[%key:common::state::off%]", "outside_working_area": "Outside working area", "poor_signal_quality": "Poor signal quality", "reference_station_communication_problem": "Reference station communication problem", @@ -172,6 +173,7 @@ "slope_too_steep": "Slope too steep", "sms_could_not_be_sent": "SMS could not be sent", "stop_button_problem": "STOP button problem", + "stopped": "[%key:common::state::stopped%]", "stuck_in_charging_station": "Stuck in charging station", "switch_cord_problem": "Switch cord problem", "temporary_battery_problem": "Temporary battery problem", @@ -187,6 +189,8 @@ "unexpected_cutting_height_adj": "Unexpected cutting height adjustment", "unexpected_error": "Unexpected error", "upside_down": "Upside down", + "wait_power_up": "Wait power up", + "wait_updating": "Wait updating", "weak_gps_signal": "Weak GPS signal", "wheel_drive_problem_left": "Left wheel drive problem", "wheel_drive_problem_rear_left": "Rear left wheel drive problem", diff --git a/homeassistant/components/husqvarna_automower/switch.py b/homeassistant/components/husqvarna_automower/switch.py index 69a3e670eda..1cfc79d5a71 100644 --- a/homeassistant/components/husqvarna_automower/switch.py +++ b/homeassistant/components/husqvarna_automower/switch.py @@ -206,12 +206,12 @@ class WorkAreaSwitchEntity(WorkAreaControlEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.coordinator.api.commands.workarea_settings( - self.mower_id, self.work_area_id, enabled=False - ) + self.mower_id, self.work_area_id + ).enabled(enabled=False) @handle_sending_exception(poll_after_sending=True) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.coordinator.api.commands.workarea_settings( - self.mower_id, self.work_area_id, enabled=True - ) + self.mower_id, self.work_area_id + ).enabled(enabled=True) diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 94137b5dd3f..0f49bacd1ef 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from contextlib import suppress +from dataclasses import dataclass import logging from typing import Any, cast @@ -22,9 +23,6 @@ from homeassistant.helpers.dispatcher import ( ) from .const import ( - CONF_INSTANCE_CLIENTS, - CONF_ON_UNLOAD, - CONF_ROOT_CLIENT, DEFAULT_NAME, DOMAIN, HYPERION_RELEASES_URL, @@ -52,15 +50,15 @@ _LOGGER = logging.getLogger(__name__) # The get_hyperion_unique_id method will create a per-entity unique id when given the # server id, an instance number and a name. -# hass.data format -# ================ -# -# hass.data[DOMAIN] = { -# : { -# "ROOT_CLIENT": , -# "ON_UNLOAD": [, ...], -# } -# } +type HyperionConfigEntry = ConfigEntry[HyperionData] + + +@dataclass +class HyperionData: + """Hyperion runtime data.""" + + root_client: client.HyperionClient + instance_clients: dict[int, client.HyperionClient] def get_hyperion_unique_id(server_id: str, instance: int, name: str) -> str: @@ -107,29 +105,29 @@ async def async_create_connect_hyperion_client( @callback def listen_for_instance_updates( hass: HomeAssistant, - config_entry: ConfigEntry, - add_func: Callable, - remove_func: Callable, + entry: HyperionConfigEntry, + add_func: Callable[[int, str], None], + remove_func: Callable[[int], None], ) -> None: """Listen for instance additions/removals.""" - hass.data[DOMAIN][config_entry.entry_id][CONF_ON_UNLOAD].extend( - [ - async_dispatcher_connect( - hass, - SIGNAL_INSTANCE_ADD.format(config_entry.entry_id), - add_func, - ), - async_dispatcher_connect( - hass, - SIGNAL_INSTANCE_REMOVE.format(config_entry.entry_id), - remove_func, - ), - ] + entry.async_on_unload( + async_dispatcher_connect( + hass, + SIGNAL_INSTANCE_ADD.format(entry.entry_id), + add_func, + ) + ) + entry.async_on_unload( + async_dispatcher_connect( + hass, + SIGNAL_INSTANCE_REMOVE.format(entry.entry_id), + remove_func, + ) ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> bool: """Set up Hyperion from a config entry.""" host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] @@ -185,12 +183,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # We need 1 root client (to manage instances being removed/added) and then 1 client # per Hyperion server instance which is shared for all entities associated with # that instance. - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - CONF_ROOT_CLIENT: hyperion_client, - CONF_INSTANCE_CLIENTS: {}, - CONF_ON_UNLOAD: [], - } + entry.runtime_data = HyperionData( + root_client=hyperion_client, + instance_clients={}, + ) async def async_instances_to_clients(response: dict[str, Any]) -> None: """Convert instances to Hyperion clients.""" @@ -203,7 +199,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_registry = dr.async_get(hass) running_instances: set[int] = set() stopped_instances: set[int] = set() - existing_instances = hass.data[DOMAIN][entry.entry_id][CONF_INSTANCE_CLIENTS] + existing_instances = entry.runtime_data.instance_clients server_id = cast(str, entry.unique_id) # In practice, an instance can be in 3 states as seen by this function: @@ -270,39 +266,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: assert hyperion_client if hyperion_client.instances is not None: await async_instances_to_clients_raw(hyperion_client.instances) - hass.data[DOMAIN][entry.entry_id][CONF_ON_UNLOAD].append( - entry.add_update_listener(_async_entry_updated) - ) + entry.async_on_unload(entry.add_update_listener(_async_entry_updated)) return True -async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def _async_entry_updated(hass: HomeAssistant, entry: HyperionConfigEntry) -> None: """Handle entry updates.""" - await hass.config_entries.async_reload(config_entry.entry_id) + await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - if unload_ok and config_entry.entry_id in hass.data[DOMAIN]: - config_data = hass.data[DOMAIN].pop(config_entry.entry_id) - for func in config_data[CONF_ON_UNLOAD]: - func() - + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: # Disconnect the shared instance clients. await asyncio.gather( *( - config_data[CONF_INSTANCE_CLIENTS][ - instance_num - ].async_client_disconnect() - for instance_num in config_data[CONF_INSTANCE_CLIENTS] + inst.async_client_disconnect() + for inst in entry.runtime_data.instance_clients.values() ) ) # Disconnect the root client. - root_client = config_data[CONF_ROOT_CLIENT] + root_client = entry.runtime_data.root_client await root_client.async_client_disconnect() return unload_ok diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index 1260be20eb2..ae9c9ba9025 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -25,7 +25,6 @@ from homeassistant.components.camera import ( Camera, async_get_still_stream, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( @@ -35,12 +34,12 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ( + HyperionConfigEntry, get_hyperion_device_id, get_hyperion_unique_id, listen_for_instance_updates, ) from .const import ( - CONF_INSTANCE_CLIENTS, DOMAIN, HYPERION_MANUFACTURER_NAME, HYPERION_MODEL_NAME, @@ -53,12 +52,11 @@ IMAGE_STREAM_JPG_SENTINEL = "data:image/jpg;base64," async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HyperionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Hyperion platform from config entry.""" - entry_data = hass.data[DOMAIN][config_entry.entry_id] - server_id = config_entry.unique_id + server_id = entry.unique_id def camera_unique_id(instance_num: int) -> str: """Return the camera unique_id.""" @@ -75,7 +73,7 @@ async def async_setup_entry( server_id, instance_num, instance_name, - entry_data[CONF_INSTANCE_CLIENTS][instance_num], + entry.runtime_data.instance_clients[instance_num], ) ] ) @@ -91,7 +89,7 @@ async def async_setup_entry( ), ) - listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) + listen_for_instance_updates(hass, entry, instance_add, instance_remove) # A note on Hyperion streaming semantics: diff --git a/homeassistant/components/hyperion/const.py b/homeassistant/components/hyperion/const.py index 3d44dd35e08..ac04d6dad3c 100644 --- a/homeassistant/components/hyperion/const.py +++ b/homeassistant/components/hyperion/const.py @@ -3,10 +3,7 @@ CONF_AUTH_ID = "auth_id" CONF_CREATE_TOKEN = "create_token" CONF_INSTANCE = "instance" -CONF_INSTANCE_CLIENTS = "INSTANCE_CLIENTS" -CONF_ON_UNLOAD = "ON_UNLOAD" CONF_PRIORITY = "priority" -CONF_ROOT_CLIENT = "ROOT_CLIENT" CONF_EFFECT_HIDE_LIST = "effect_hide_list" CONF_EFFECT_SHOW_LIST = "effect_show_list" diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index f8932a682ab..4cf0ed0f5e2 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Callable, Mapping, Sequence import functools import logging -from types import MappingProxyType from typing import Any from hyperion import client, const @@ -18,7 +17,6 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( @@ -29,13 +27,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from . import ( + HyperionConfigEntry, get_hyperion_device_id, get_hyperion_unique_id, listen_for_instance_updates, ) from .const import ( CONF_EFFECT_HIDE_LIST, - CONF_INSTANCE_CLIENTS, CONF_PRIORITY, DEFAULT_ORIGIN, DEFAULT_PRIORITY, @@ -75,28 +73,26 @@ ICON_EFFECT = "mdi:lava-lamp" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HyperionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Hyperion platform from config entry.""" - entry_data = hass.data[DOMAIN][config_entry.entry_id] - server_id = config_entry.unique_id + server_id = entry.unique_id @callback def instance_add(instance_num: int, instance_name: str) -> None: """Add entities for a new Hyperion instance.""" assert server_id - args = ( - server_id, - instance_num, - instance_name, - config_entry.options, - entry_data[CONF_INSTANCE_CLIENTS][instance_num], - ) async_add_entities( [ - HyperionLight(*args), + HyperionLight( + server_id, + instance_num, + instance_name, + entry.options, + entry.runtime_data.instance_clients[instance_num], + ), ] ) @@ -111,7 +107,7 @@ async def async_setup_entry( ), ) - listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) + listen_for_instance_updates(hass, entry, instance_add, instance_remove) class HyperionLight(LightEntity): @@ -129,7 +125,7 @@ class HyperionLight(LightEntity): server_id: str, instance_num: int, instance_name: str, - options: MappingProxyType[str, Any], + options: Mapping[str, Any], hyperion_client: client.HyperionClient, ) -> None: """Initialize the light.""" diff --git a/homeassistant/components/hyperion/sensor.py b/homeassistant/components/hyperion/sensor.py index 42b41acea96..bec17cfbd2f 100644 --- a/homeassistant/components/hyperion/sensor.py +++ b/homeassistant/components/hyperion/sensor.py @@ -19,7 +19,6 @@ from hyperion.const import ( ) from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( @@ -29,12 +28,12 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ( + HyperionConfigEntry, get_hyperion_device_id, get_hyperion_unique_id, listen_for_instance_updates, ) from .const import ( - CONF_INSTANCE_CLIENTS, DOMAIN, HYPERION_MANUFACTURER_NAME, HYPERION_MODEL_NAME, @@ -62,12 +61,11 @@ def _sensor_unique_id(server_id: str, instance_num: int, suffix: str) -> str: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HyperionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Hyperion platform from config entry.""" - entry_data = hass.data[DOMAIN][config_entry.entry_id] - server_id = config_entry.unique_id + server_id = entry.unique_id @callback def instance_add(instance_num: int, instance_name: str) -> None: @@ -78,7 +76,7 @@ async def async_setup_entry( server_id, instance_num, instance_name, - entry_data[CONF_INSTANCE_CLIENTS][instance_num], + entry.runtime_data.instance_clients[instance_num], PRIORITY_SENSOR_DESCRIPTION, ) ] @@ -98,7 +96,7 @@ async def async_setup_entry( ), ) - listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) + listen_for_instance_updates(hass, entry, instance_add, instance_remove) class HyperionSensor(SensorEntity): diff --git a/homeassistant/components/hyperion/switch.py b/homeassistant/components/hyperion/switch.py index 8b66783e889..c082c685304 100644 --- a/homeassistant/components/hyperion/switch.py +++ b/homeassistant/components/hyperion/switch.py @@ -26,7 +26,6 @@ from hyperion.const import ( ) from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -38,12 +37,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify from . import ( + HyperionConfigEntry, get_hyperion_device_id, get_hyperion_unique_id, listen_for_instance_updates, ) from .const import ( - CONF_INSTANCE_CLIENTS, DOMAIN, HYPERION_MANUFACTURER_NAME, HYPERION_MODEL_NAME, @@ -89,12 +88,11 @@ def _component_to_translation_key(component: str) -> str: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HyperionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Hyperion platform from config entry.""" - entry_data = hass.data[DOMAIN][config_entry.entry_id] - server_id = config_entry.unique_id + server_id = entry.unique_id @callback def instance_add(instance_num: int, instance_name: str) -> None: @@ -106,7 +104,7 @@ async def async_setup_entry( instance_num, instance_name, component, - entry_data[CONF_INSTANCE_CLIENTS][instance_num], + entry.runtime_data.instance_clients[instance_num], ) for component in COMPONENT_SWITCHES ) @@ -123,7 +121,7 @@ async def async_setup_entry( ), ) - listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) + listen_for_instance_updates(hass, entry, instance_add, instance_remove) class HyperionComponentSwitch(SwitchEntity): diff --git a/homeassistant/components/iaqualink/entity.py b/homeassistant/components/iaqualink/entity.py index 437611e5a5f..d0176ed8bfe 100644 --- a/homeassistant/components/iaqualink/entity.py +++ b/homeassistant/components/iaqualink/entity.py @@ -32,7 +32,6 @@ class AqualinkEntity(Entity): manufacturer=dev.manufacturer, model=dev.model, name=dev.label, - via_device=(DOMAIN, dev.system.serial), ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index 7e05bd72f0b..a0742865438 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/iaqualink", "iot_class": "cloud_polling", "loggers": ["iaqualink"], - "requirements": ["iaqualink==0.5.3", "h2==4.1.0"], + "requirements": ["iaqualink==0.5.3", "h2==4.2.0"], "single_config_entry": true } diff --git a/homeassistant/components/idasen_desk/strings.json b/homeassistant/components/idasen_desk/strings.json index ccac87a75e0..ff0cb5b8ae6 100644 --- a/homeassistant/components/idasen_desk/strings.json +++ b/homeassistant/components/idasen_desk/strings.json @@ -26,10 +26,10 @@ "entity": { "button": { "connect": { - "name": "Connect" + "name": "[%key:common::action::connect%]" }, "disconnect": { - "name": "Disconnect" + "name": "[%key:common::action::disconnect%]" } }, "sensor": { diff --git a/homeassistant/components/ifttt/strings.json b/homeassistant/components/ifttt/strings.json index 5ba0812697f..df5a2bc9d93 100644 --- a/homeassistant/components/ifttt/strings.json +++ b/homeassistant/components/ifttt/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up the IFTTT Webhook Applet", + "title": "Set up the IFTTT webhook applet", "description": "Are you sure you want to set up IFTTT?" } }, @@ -12,7 +12,7 @@ "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, "create_entry": { - "default": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT Webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + "default": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." } }, "services": { @@ -32,7 +32,7 @@ }, "trigger": { "name": "Trigger", - "description": "Triggers the configured IFTTT Webhook.", + "description": "Triggers the configured IFTTT webhook.", "fields": { "event": { "name": "Event", diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index e43377a3230..bc01476d509 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==11.1.0"] + "requirements": ["Pillow==11.2.1"] } diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 8ff5d838199..0f6f99dff65 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -136,7 +136,7 @@ "services": { "fetch": { "name": "Fetch message", - "description": "Fetch an email message from the server.", + "description": "Fetches an email message from the server.", "fields": { "entry": { "name": "Entry", @@ -150,7 +150,7 @@ }, "seen": { "name": "Mark message as seen", - "description": "Mark an email as seen.", + "description": "Marks an email as seen.", "fields": { "entry": { "name": "Entry", @@ -164,7 +164,7 @@ }, "move": { "name": "Move message", - "description": "Move an email to a target folder.", + "description": "Moves an email to a target folder.", "fields": { "entry": { "name": "[%key:component::imap::services::seen::fields::entry::name%]", @@ -186,7 +186,7 @@ }, "delete": { "name": "Delete message", - "description": "Delete an email.", + "description": "Deletes an email.", "fields": { "entry": { "name": "[%key:component::imap::services::seen::fields::entry::name%]", diff --git a/homeassistant/components/imeon_inverter/__init__.py b/homeassistant/components/imeon_inverter/__init__.py new file mode 100644 index 00000000000..0676731f375 --- /dev/null +++ b/homeassistant/components/imeon_inverter/__init__.py @@ -0,0 +1,31 @@ +"""Initialize the Imeon component.""" + +from __future__ import annotations + +import logging + +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS +from .coordinator import InverterConfigEntry, InverterCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: InverterConfigEntry) -> bool: + """Handle the creation of a new config entry for the integration (asynchronous).""" + + # Create the corresponding HUB + coordinator = InverterCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + # Call for HUB creation then each entity as a List + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: InverterConfigEntry) -> bool: + """Handle entry unloading.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/imeon_inverter/config_flow.py b/homeassistant/components/imeon_inverter/config_flow.py new file mode 100644 index 00000000000..fadb2c65446 --- /dev/null +++ b/homeassistant/components/imeon_inverter/config_flow.py @@ -0,0 +1,114 @@ +"""Config flow for Imeon integration.""" + +import logging +from typing import Any +from urllib.parse import urlparse + +from imeon_inverter_api.inverter import Inverter +import voluptuous as vol + +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_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.typing import VolDictType + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ImeonInverterConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the initial setup flow for Imeon Inverters.""" + + _host: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step for creating a new configuration entry.""" + + errors: dict[str, str] = {} + + if user_input is not None: + # User have to provide the hostname if device is not discovered + host = self._host or user_input[CONF_HOST] + + async with Inverter(host) as client: + try: + # Check connection + if await client.login( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ): + serial = await client.get_serial() + + else: + errors["base"] = "invalid_auth" + + except TimeoutError: + errors["base"] = "cannot_connect" + + except ValueError as e: + if "Host invalid" in str(e): + errors["base"] = "invalid_host" + + elif "Route invalid" in str(e): + errors["base"] = "invalid_route" + + else: + errors["base"] = "unknown" + _LOGGER.exception( + "Unexpected error occurred while connecting to the Imeon" + ) + + if not errors: + # Check if entry already exists + await self.async_set_unique_id(serial, raise_on_progress=False) + self._abort_if_unique_id_configured() + + # Create a new configuration entry if login succeeds + return self.async_create_entry( + title=f"Imeon {serial}", data={CONF_HOST: host, **user_input} + ) + + host_schema: VolDictType = ( + {vol.Required(CONF_HOST): str} if not self._host else {} + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + **host_schema, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def async_step_ssdp( + self, discovery_info: SsdpServiceInfo + ) -> ConfigFlowResult: + """Handle a SSDP discovery.""" + + host = str(urlparse(discovery_info.ssdp_location).hostname) + serial = discovery_info.upnp.get(ATTR_UPNP_SERIAL, "") + + if not serial: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + self._host = host + + self.context["title_placeholders"] = { + "model": discovery_info.upnp.get(ATTR_UPNP_MODEL_NUMBER, ""), + "serial": serial, + } + + return await self.async_step_user() diff --git a/homeassistant/components/imeon_inverter/const.py b/homeassistant/components/imeon_inverter/const.py new file mode 100644 index 00000000000..c71a8c72d11 --- /dev/null +++ b/homeassistant/components/imeon_inverter/const.py @@ -0,0 +1,9 @@ +"""Constant for Imeon component.""" + +from homeassistant.const import Platform + +DOMAIN = "imeon_inverter" +TIMEOUT = 20 +PLATFORMS = [ + Platform.SENSOR, +] diff --git a/homeassistant/components/imeon_inverter/coordinator.py b/homeassistant/components/imeon_inverter/coordinator.py new file mode 100644 index 00000000000..8342240b9ff --- /dev/null +++ b/homeassistant/components/imeon_inverter/coordinator.py @@ -0,0 +1,97 @@ +"""Coordinator for Imeon integration.""" + +from __future__ import annotations + +from asyncio import timeout +from datetime import timedelta +import logging + +from aiohttp import ClientError +from imeon_inverter_api.inverter import Inverter + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import TIMEOUT + +HUBNAME = "imeon_inverter_hub" +INTERVAL = timedelta(seconds=60) +_LOGGER = logging.getLogger(__name__) + +type InverterConfigEntry = ConfigEntry[InverterCoordinator] + + +# HUB CREATION # +class InverterCoordinator(DataUpdateCoordinator[dict[str, str | float | int]]): + """Each inverter is it's own HUB, thus it's own data set. + + This allows this integration to handle as many + inverters as possible in parallel. + """ + + config_entry: InverterConfigEntry + + # Implement methods to fetch and update data + def __init__( + self, + hass: HomeAssistant, + entry: InverterConfigEntry, + ) -> None: + """Initialize data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=HUBNAME, + update_interval=INTERVAL, + config_entry=entry, + ) + + self._api = Inverter(entry.data[CONF_HOST]) + + @property + def api(self) -> Inverter: + """Return the inverter object.""" + return self._api + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + async with timeout(TIMEOUT): + await self._api.login( + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], + ) + + await self._api.init() + + async def _async_update_data(self) -> dict[str, str | float | int]: + """Fetch and store newest data from API. + + This is the place to where entities can get their data. + It also includes the login process. + """ + + data: dict[str, str | float | int] = {} + + async with timeout(TIMEOUT): + await self._api.login( + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], + ) + + # Fetch data using distant API + try: + await self._api.update() + except (ValueError, ClientError) as e: + raise UpdateFailed(e) from e + + # Store data + for key, val in self._api.storage.items(): + if key == "timeline": + data[key] = val + else: + for sub_key, sub_val in val.items(): + data[f"{key}_{sub_key}"] = sub_val + + return data diff --git a/homeassistant/components/imeon_inverter/icons.json b/homeassistant/components/imeon_inverter/icons.json new file mode 100644 index 00000000000..1c74cf4c745 --- /dev/null +++ b/homeassistant/components/imeon_inverter/icons.json @@ -0,0 +1,159 @@ +{ + "entity": { + "sensor": { + "battery_autonomy": { + "default": "mdi:battery-clock" + }, + "battery_charge_time": { + "default": "mdi:battery-charging" + }, + "battery_power": { + "default": "mdi:battery" + }, + "battery_soc": { + "default": "mdi:battery-charging-100" + }, + "battery_stored": { + "default": "mdi:battery" + }, + "grid_current_l1": { + "default": "mdi:current-ac" + }, + "grid_current_l2": { + "default": "mdi:current-ac" + }, + "grid_current_l3": { + "default": "mdi:current-ac" + }, + "grid_frequency": { + "default": "mdi:sine-wave" + }, + "grid_voltage_l1": { + "default": "mdi:flash" + }, + "grid_voltage_l2": { + "default": "mdi:flash" + }, + "grid_voltage_l3": { + "default": "mdi:flash" + }, + "input_power_l1": { + "default": "mdi:power-socket" + }, + "input_power_l2": { + "default": "mdi:power-socket" + }, + "input_power_l3": { + "default": "mdi:power-socket" + }, + "input_power_total": { + "default": "mdi:power-plug" + }, + "inverter_charging_current_limit": { + "default": "mdi:current-dc" + }, + "inverter_injection_power_limit": { + "default": "mdi:power-socket" + }, + "meter_power": { + "default": "mdi:power-plug" + }, + "meter_power_protocol": { + "default": "mdi:protocol" + }, + "output_current_l1": { + "default": "mdi:current-ac" + }, + "output_current_l2": { + "default": "mdi:current-ac" + }, + "output_current_l3": { + "default": "mdi:current-ac" + }, + "output_frequency": { + "default": "mdi:sine-wave" + }, + "output_power_l1": { + "default": "mdi:power-socket" + }, + "output_power_l2": { + "default": "mdi:power-socket" + }, + "output_power_l3": { + "default": "mdi:power-socket" + }, + "output_power_total": { + "default": "mdi:power-plug" + }, + "output_voltage_l1": { + "default": "mdi:flash" + }, + "output_voltage_l2": { + "default": "mdi:flash" + }, + "output_voltage_l3": { + "default": "mdi:flash" + }, + "pv_consumed": { + "default": "mdi:solar-power" + }, + "pv_injected": { + "default": "mdi:solar-power" + }, + "pv_power_1": { + "default": "mdi:solar-power" + }, + "pv_power_2": { + "default": "mdi:solar-power" + }, + "pv_power_total": { + "default": "mdi:solar-power" + }, + "temp_air_temperature": { + "default": "mdi:thermometer" + }, + "temp_component_temperature": { + "default": "mdi:thermometer" + }, + "monitoring_building_consumption": { + "default": "mdi:home-lightning-bolt" + }, + "monitoring_economy_factor": { + "default": "mdi:chart-bar" + }, + "monitoring_grid_consumption": { + "default": "mdi:transmission-tower" + }, + "monitoring_grid_injection": { + "default": "mdi:transmission-tower-export" + }, + "monitoring_grid_power_flow": { + "default": "mdi:power-plug" + }, + "monitoring_self_consumption": { + "default": "mdi:percent" + }, + "monitoring_self_sufficiency": { + "default": "mdi:percent" + }, + "monitoring_solar_production": { + "default": "mdi:solar-power" + }, + "monitoring_minute_building_consumption": { + "default": "mdi:home-lightning-bolt" + }, + "monitoring_minute_grid_consumption": { + "default": "mdi:transmission-tower" + }, + "monitoring_minute_grid_injection": { + "default": "mdi:transmission-tower-export" + }, + "monitoring_minute_grid_power_flow": { + "default": "mdi:power-plug" + }, + "monitoring_minute_solar_production": { + "default": "mdi:solar-power" + } + } + } +} diff --git a/homeassistant/components/imeon_inverter/manifest.json b/homeassistant/components/imeon_inverter/manifest.json new file mode 100644 index 00000000000..1398521dc45 --- /dev/null +++ b/homeassistant/components/imeon_inverter/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "imeon_inverter", + "name": "Imeon Inverter", + "codeowners": ["@Imeon-Energy"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/imeon_inverter", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["imeon_inverter_api==0.3.12"], + "ssdp": [ + { + "manufacturer": "IMEON", + "deviceType": "urn:schemas-upnp-org:device:Basic:1", + "st": "upnp:rootdevice" + } + ] +} diff --git a/homeassistant/components/imeon_inverter/quality_scale.yaml b/homeassistant/components/imeon_inverter/quality_scale.yaml new file mode 100644 index 00000000000..6e364977697 --- /dev/null +++ b/homeassistant/components/imeon_inverter/quality_scale.yaml @@ -0,0 +1,71 @@ +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: This integration doesn't have sensors that subscribe to events. + dependency-transparency: done + action-setup: + status: exempt + comment: This integration does not have any service for now. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: + status: exempt + comment: This integration does not have any service for now. + brands: done + # Silver + action-exceptions: + status: exempt + comment: This integration does not have any service for now. + 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: todo + test-coverage: done + # 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: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Device type integration. + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Currently no issues. + stale-devices: + status: exempt + comment: Device type integration. + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/imeon_inverter/sensor.py b/homeassistant/components/imeon_inverter/sensor.py new file mode 100644 index 00000000000..b7a01c3cf17 --- /dev/null +++ b/homeassistant/components/imeon_inverter/sensor.py @@ -0,0 +1,464 @@ +"""Imeon inverter sensor support.""" + +import logging + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import InverterCoordinator + +type InverterConfigEntry = ConfigEntry[InverterCoordinator] + +_LOGGER = logging.getLogger(__name__) + + +ENTITY_DESCRIPTIONS = ( + # Battery + SensorEntityDescription( + key="battery_autonomy", + translation_key="battery_autonomy", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="battery_charge_time", + translation_key="battery_charge_time", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + key="battery_power", + translation_key="battery_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="battery_soc", + translation_key="battery_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="battery_stored", + translation_key="battery_stored", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + state_class=SensorStateClass.TOTAL, + ), + # Grid + SensorEntityDescription( + key="grid_current_l1", + translation_key="grid_current_l1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_current_l2", + translation_key="grid_current_l2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_current_l3", + translation_key="grid_current_l3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_frequency", + translation_key="grid_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_voltage_l1", + translation_key="grid_voltage_l1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_voltage_l2", + translation_key="grid_voltage_l2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_voltage_l3", + translation_key="grid_voltage_l3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + # AC Input + SensorEntityDescription( + key="input_power_l1", + translation_key="input_power_l1", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="input_power_l2", + translation_key="input_power_l2", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="input_power_l3", + translation_key="input_power_l3", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="input_power_total", + translation_key="input_power_total", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + # Inverter settings + SensorEntityDescription( + key="inverter_charging_current_limit", + translation_key="inverter_charging_current_limit", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="inverter_injection_power_limit", + translation_key="inverter_injection_power_limit", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + # Meter + SensorEntityDescription( + key="meter_power", + translation_key="meter_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="meter_power_protocol", + translation_key="meter_power_protocol", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + # AC Output + SensorEntityDescription( + key="output_current_l1", + translation_key="output_current_l1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_current_l2", + translation_key="output_current_l2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_current_l3", + translation_key="output_current_l3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_frequency", + translation_key="output_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_power_l1", + translation_key="output_power_l1", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_power_l2", + translation_key="output_power_l2", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_power_l3", + translation_key="output_power_l3", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_power_total", + translation_key="output_power_total", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_voltage_l1", + translation_key="output_voltage_l1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_voltage_l2", + translation_key="output_voltage_l2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_voltage_l3", + translation_key="output_voltage_l3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + # Solar Panel + SensorEntityDescription( + key="pv_consumed", + translation_key="pv_consumed", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + key="pv_injected", + translation_key="pv_injected", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + key="pv_power_1", + translation_key="pv_power_1", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="pv_power_2", + translation_key="pv_power_2", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="pv_power_total", + translation_key="pv_power_total", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + # Temperature + SensorEntityDescription( + key="temp_air_temperature", + translation_key="temp_air_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="temp_component_temperature", + translation_key="temp_component_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + # Monitoring (data over the last 24 hours) + SensorEntityDescription( + key="monitoring_building_consumption", + translation_key="monitoring_building_consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_economy_factor", + translation_key="monitoring_economy_factor", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_grid_consumption", + translation_key="monitoring_grid_consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_grid_injection", + translation_key="monitoring_grid_injection", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_grid_power_flow", + translation_key="monitoring_grid_power_flow", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_self_consumption", + translation_key="monitoring_self_consumption", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_self_sufficiency", + translation_key="monitoring_self_sufficiency", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_solar_production", + translation_key="monitoring_solar_production", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + # Monitoring (instant minute data) + SensorEntityDescription( + key="monitoring_minute_building_consumption", + translation_key="monitoring_minute_building_consumption", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_minute_grid_consumption", + translation_key="monitoring_minute_grid_consumption", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_minute_grid_injection", + translation_key="monitoring_minute_grid_injection", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_minute_grid_power_flow", + translation_key="monitoring_minute_grid_power_flow", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_minute_solar_production", + translation_key="monitoring_minute_solar_production", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: InverterConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create each sensor for a given config entry.""" + + coordinator = entry.runtime_data + + # Init sensor entities + async_add_entities( + InverterSensor(coordinator, entry, description) + for description in ENTITY_DESCRIPTIONS + ) + + +class InverterSensor(CoordinatorEntity[InverterCoordinator], SensorEntity): + """A sensor that returns numerical values with units.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: InverterCoordinator, + entry: InverterConfigEntry, + description: SensorEntityDescription, + ) -> None: + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + self.entity_description = description + self._inverter = coordinator.api.inverter + self.data_key = description.key + assert entry.unique_id + self._attr_unique_id = f"{entry.unique_id}_{self.data_key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.unique_id)}, + name="Imeon inverter", + manufacturer="Imeon Energy", + model=self._inverter.get("inverter"), + sw_version=self._inverter.get("software"), + ) + + @property + def native_value(self) -> StateType | None: + """Value of the sensor.""" + return self.coordinator.data.get(self.data_key) diff --git a/homeassistant/components/imeon_inverter/strings.json b/homeassistant/components/imeon_inverter/strings.json new file mode 100644 index 00000000000..218e1c4e4aa --- /dev/null +++ b/homeassistant/components/imeon_inverter/strings.json @@ -0,0 +1,187 @@ +{ + "config": { + "flow_title": "Imeon {model} ({serial})", + "step": { + "user": { + "title": "Add Imeon inverter", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP of your inverter", + "username": "The username of your OS One account", + "password": "The password of your OS One account" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "invalid_route": "Unable to request the API, make sure 'API Module' is enabled on your device", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "sensor": { + "battery_autonomy": { + "name": "Battery autonomy" + }, + "battery_charge_time": { + "name": "Battery charge time" + }, + "battery_power": { + "name": "Battery power" + }, + "battery_soc": { + "name": "Battery state of charge" + }, + "battery_stored": { + "name": "Battery stored" + }, + "grid_current_l1": { + "name": "Grid current L1" + }, + "grid_current_l2": { + "name": "Grid current L2" + }, + "grid_current_l3": { + "name": "Grid current L3" + }, + "grid_frequency": { + "name": "Grid frequency" + }, + "grid_voltage_l1": { + "name": "Grid voltage L1" + }, + "grid_voltage_l2": { + "name": "Grid voltage L2" + }, + "grid_voltage_l3": { + "name": "Grid voltage L3" + }, + "input_power_l1": { + "name": "Input power L1" + }, + "input_power_l2": { + "name": "Input power L2" + }, + "input_power_l3": { + "name": "Input power L3" + }, + "input_power_total": { + "name": "Input power total" + }, + "inverter_charging_current_limit": { + "name": "Charging current limit" + }, + "inverter_injection_power_limit": { + "name": "Injection power limit" + }, + "meter_power": { + "name": "Meter power" + }, + "meter_power_protocol": { + "name": "Meter power protocol" + }, + "output_current_l1": { + "name": "Output current L1" + }, + "output_current_l2": { + "name": "Output current L2" + }, + "output_current_l3": { + "name": "Output current L3" + }, + "output_frequency": { + "name": "Output frequency" + }, + "output_power_l1": { + "name": "Output power L1" + }, + "output_power_l2": { + "name": "Output power L2" + }, + "output_power_l3": { + "name": "Output power L3" + }, + "output_power_total": { + "name": "Output power total" + }, + "output_voltage_l1": { + "name": "Output voltage L1" + }, + "output_voltage_l2": { + "name": "Output voltage L2" + }, + "output_voltage_l3": { + "name": "Output voltage L3" + }, + "pv_consumed": { + "name": "PV consumed" + }, + "pv_injected": { + "name": "PV injected" + }, + "pv_power_1": { + "name": "PV power 1" + }, + "pv_power_2": { + "name": "PV power 2" + }, + "pv_power_total": { + "name": "PV power total" + }, + "temp_air_temperature": { + "name": "Air temperature" + }, + "temp_component_temperature": { + "name": "Component temperature" + }, + "monitoring_building_consumption": { + "name": "Monitoring building consumption" + }, + "monitoring_economy_factor": { + "name": "Monitoring economy factor" + }, + "monitoring_grid_consumption": { + "name": "Monitoring grid consumption" + }, + "monitoring_grid_injection": { + "name": "Monitoring grid injection" + }, + "monitoring_grid_power_flow": { + "name": "Monitoring grid power flow" + }, + "monitoring_self_consumption": { + "name": "Monitoring self-consumption" + }, + "monitoring_self_sufficiency": { + "name": "Monitoring self-sufficiency" + }, + "monitoring_solar_production": { + "name": "Monitoring solar production" + }, + "monitoring_minute_building_consumption": { + "name": "Monitoring building consumption (minute)" + }, + "monitoring_minute_grid_consumption": { + "name": "Monitoring grid consumption (minute)" + }, + "monitoring_minute_grid_injection": { + "name": "Monitoring grid injection (minute)" + }, + "monitoring_minute_grid_power_flow": { + "name": "Monitoring grid power flow (minute)" + }, + "monitoring_minute_solar_production": { + "name": "Monitoring solar production (minute)" + } + } + } +} diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 3d8b34055fd..e2d6e2bf584 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", + "quality_scale": "silver", "requirements": ["imgw_pib==1.0.10"] } diff --git a/homeassistant/components/imgw_pib/quality_scale.yaml b/homeassistant/components/imgw_pib/quality_scale.yaml new file mode 100644 index 00000000000..6634c915255 --- /dev/null +++ b/homeassistant/components/imgw_pib/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not register services. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration does not register services. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration does not register services. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: No authentication required. + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: The integration is a cloud service and thus does not support discovery. + discovery: + status: exempt + comment: The integration is a cloud service and thus does not support discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: exempt + comment: This is a service, which doesn't integrate with any devices. + docs-supported-functions: todo + docs-troubleshooting: + status: exempt + comment: No known issues that could be resolved by the user. + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration has a fixed single service. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: This integration does not have any entities that should disabled by default. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: Only parameter that could be changed station_id would force a new config entry. + repair-issues: + status: exempt + comment: This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: exempt + comment: This integration has a fixed single service. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index 33cd3cb3917..9b7f132da6f 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -16,7 +16,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "cannot_connect": "Failed to connect" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, "entity": { diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index d44ba15507e..c10cbe5be5b 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -12,7 +12,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, EntityCategory, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -43,7 +43,6 @@ async def async_setup_entry( 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 diff --git a/homeassistant/components/incomfort/icons.json b/homeassistant/components/incomfort/icons.json index 6e33ac75eee..56ba6f545de 100644 --- a/homeassistant/components/incomfort/icons.json +++ b/homeassistant/components/incomfort/icons.json @@ -32,6 +32,7 @@ "sensor_test": "mdi:thermometer-check", "central_heating": "mdi:radiator", "standby": "mdi:water-boiler-off", + "off": "mdi:water-boiler-off", "postrun_boyler": "mdi:water-boiler-auto", "service": "mdi:progress-wrench", "tapwater": "mdi:faucet", diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index 825f198dd30..6214eb03f40 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -11,5 +11,5 @@ "iot_class": "local_polling", "loggers": ["incomfortclient"], "quality_scale": "platinum", - "requirements": ["incomfort-client==0.6.7"] + "requirements": ["incomfort-client==0.6.8"] } diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 4e1168686df..bc9085c3f20 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -118,14 +118,15 @@ "tapwater_int": "Tap water internal", "sensor_test": "Sensor test", "central_heating": "Central heating", - "standby": "Stand-by", + "standby": "[%key:common::state::standby%]", + "off": "[%key:common::state::off%]", "postrun_boyler": "Post run boiler", "service": "Service", "tapwater": "Tap water", "postrun_ch": "Post run central heating", "boiler_int": "Boiler internal", "buffer": "Buffer", - "sensor_fault_after_self_check_e0": "Sensor fault after self check", + "sensor_fault_after_self_check_e0": "Sensor fault after self-check", "cv_temperature_too_high_e1": "Temperature too high", "s1_and_s2_interchanged_e2": "S1 and S2 interchanged", "no_flame_signal_e4": "No flame signal", diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py index 9dd058e841a..8daa94f2f6d 100644 --- a/homeassistant/components/inkbird/__init__.py +++ b/homeassistant/components/inkbird/__init__.py @@ -2,65 +2,36 @@ from __future__ import annotations -import logging +from typing import Any -from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate - -from homeassistant.components.bluetooth import ( - BluetoothScanningMode, - BluetoothServiceInfo, -) -from homeassistant.components.bluetooth.passive_update_processor import ( - PassiveBluetoothProcessorCoordinator, -) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant -from .const import CONF_DEVICE_TYPE, DOMAIN +from .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE +from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator + +INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator] PLATFORMS: list[Platform] = [Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: INKBIRDConfigEntry) -> bool: """Set up INKBIRD BLE device from a config entry.""" - address = entry.unique_id - assert address is not None + assert entry.unique_id is not None device_type: str | None = entry.data.get(CONF_DEVICE_TYPE) - data = INKBIRDBluetoothDeviceData(device_type) - - @callback - def _async_on_update(service_info: BluetoothServiceInfo) -> SensorUpdate: - """Handle update callback from the passive BLE processor.""" - nonlocal device_type - update = data.update(service_info) - if device_type is None and data.device_type is not None: - device_type_str = str(data.device_type) - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_DEVICE_TYPE: device_type_str} - ) - device_type = device_type_str - return update - - coordinator = PassiveBluetoothProcessorCoordinator( - hass, - _LOGGER, - address=address, - mode=BluetoothScanningMode.ACTIVE, - update_method=_async_on_update, + device_data: dict[str, Any] | None = entry.data.get(CONF_DEVICE_DATA) + coordinator = INKBIRDActiveBluetoothProcessorCoordinator( + hass, entry, device_type, device_data ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await coordinator.async_init() + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # only start after all platforms have had a chance to subscribe entry.async_on_unload(coordinator.async_start()) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: INKBIRDConfigEntry) -> 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/inkbird/config_flow.py b/homeassistant/components/inkbird/config_flow.py index 09dd31a9cf6..9ce20baaeda 100644 --- a/homeassistant/components/inkbird/config_flow.py +++ b/homeassistant/components/inkbird/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.components.bluetooth import ( from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ADDRESS -from .const import DOMAIN +from .const import CONF_DEVICE_TYPE, DOMAIN class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): @@ -26,7 +26,7 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): """Initialize the config flow.""" self._discovery_info: BluetoothServiceInfoBleak | None = None self._discovered_device: DeviceData | None = None - self._discovered_devices: dict[str, str] = {} + self._discovered_devices: dict[str, tuple[str, str]] = {} async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak @@ -51,7 +51,10 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): discovery_info = self._discovery_info title = device.title or device.get_device_name() or discovery_info.name if user_input is not None: - return self.async_create_entry(title=title, data={}) + return self.async_create_entry( + title=title, + data={CONF_DEVICE_TYPE: str(self._discovered_device.device_type)}, + ) self._set_confirm_only() placeholders = {"name": title} @@ -68,8 +71,9 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): address = user_input[CONF_ADDRESS] await self.async_set_unique_id(address, raise_on_progress=False) self._abort_if_unique_id_configured() + title, device_type = self._discovered_devices[address] return self.async_create_entry( - title=self._discovered_devices[address], data={} + title=title, data={CONF_DEVICE_TYPE: device_type} ) current_addresses = self._async_current_ids(include_ignore=False) @@ -80,7 +84,8 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): device = DeviceData() if device.supported(discovery_info): self._discovered_devices[address] = ( - device.title or device.get_device_name() or discovery_info.name + device.title or device.get_device_name() or discovery_info.name, + str(device.device_type), ) if not self._discovered_devices: diff --git a/homeassistant/components/inkbird/const.py b/homeassistant/components/inkbird/const.py index 93fdcc7519c..b20e1af8de1 100644 --- a/homeassistant/components/inkbird/const.py +++ b/homeassistant/components/inkbird/const.py @@ -3,3 +3,4 @@ DOMAIN = "inkbird" CONF_DEVICE_TYPE = "device_type" +CONF_DEVICE_DATA = "device_data" diff --git a/homeassistant/components/inkbird/coordinator.py b/homeassistant/components/inkbird/coordinator.py new file mode 100644 index 00000000000..d52ebd83595 --- /dev/null +++ b/homeassistant/components/inkbird/coordinator.py @@ -0,0 +1,135 @@ +"""The INKBIRD Bluetooth integration.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import logging +from typing import Any + +from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate + +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + BluetoothServiceInfo, + BluetoothServiceInfoBleak, + async_ble_device_from_address, + async_last_service_info, +) +from homeassistant.components.bluetooth.active_update_processor import ( + ActiveBluetoothProcessorCoordinator, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.event import async_track_time_interval + +from .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +FALLBACK_POLL_INTERVAL = timedelta(seconds=180) + + +class INKBIRDActiveBluetoothProcessorCoordinator( + ActiveBluetoothProcessorCoordinator[SensorUpdate] +): + """Coordinator for INKBIRD Bluetooth devices.""" + + _data: INKBIRDBluetoothDeviceData + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + device_type: str | None, + device_data: dict[str, Any] | None, + ) -> None: + """Initialize the INKBIRD Bluetooth processor coordinator.""" + self._entry = entry + self._device_type = device_type + self._device_data = device_data + address = entry.unique_id + assert address is not None + super().__init__( + hass=hass, + logger=_LOGGER, + address=address, + mode=BluetoothScanningMode.ACTIVE, + update_method=self._async_on_update, + needs_poll_method=self._async_needs_poll, + poll_method=self._async_poll_data, + ) + + async def async_init(self) -> None: + """Initialize the coordinator.""" + self._data = INKBIRDBluetoothDeviceData( + self._device_type, + self._device_data, + self.async_set_updated_data, + self._async_device_data_changed, + ) + if not self._data.uses_notify: + self._entry.async_on_unload( + async_track_time_interval( + self.hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL + ) + ) + return + if not (service_info := async_last_service_info(self.hass, self.address)): + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="no_advertisement", + translation_placeholders={"address": self.address}, + ) + await self._data.async_start(service_info, service_info.device) + self._entry.async_on_unload(self._data.async_stop) + + async def _async_poll_data( + self, last_service_info: BluetoothServiceInfoBleak + ) -> SensorUpdate: + """Poll the device.""" + return await self._data.async_poll(last_service_info.device) + + @callback + def _async_needs_poll( + self, service_info: BluetoothServiceInfoBleak, last_poll: float | None + ) -> bool: + return ( + not self.hass.is_stopping + and self._data.poll_needed(service_info, last_poll) + and bool( + async_ble_device_from_address( + self.hass, service_info.device.address, connectable=True + ) + ) + ) + + @callback + def _async_device_data_changed(self, new_device_data: dict[str, Any]) -> None: + """Handle device data changed.""" + self.hass.config_entries.async_update_entry( + self._entry, data={**self._entry.data, CONF_DEVICE_DATA: new_device_data} + ) + + @callback + def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate: + """Handle update callback from the passive BLE processor.""" + update = self._data.update(service_info) + if ( + self._entry.data.get(CONF_DEVICE_TYPE) is None + and self._data.device_type is not None + ): + device_type_str = str(self._data.device_type) + self.hass.config_entries.async_update_entry( + self._entry, + data={**self._entry.data, CONF_DEVICE_TYPE: device_type_str}, + ) + return update + + @callback + def _async_schedule_poll(self, _: datetime) -> None: + """Schedule a poll of the device.""" + if self._last_service_info and self._async_needs_poll( + self._last_service_info, self._last_poll + ): + self._debounced_poll.async_schedule_call() diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index aaa9c4b3473..38d406da62e 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -33,6 +33,19 @@ { "local_name": "ITH-21-B", "connectable": false + }, + { + "local_name": "IBS-P02B", + "connectable": false + }, + { + "local_name": "Ink@IAM-T1", + "connectable": true + }, + { + "manufacturer_id": 12628, + "manufacturer_data_start": [65, 67, 45], + "connectable": true } ], "codeowners": ["@bdraco"], @@ -40,5 +53,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.9.0"] + "requirements": ["inkbird-ble==0.16.1"] } diff --git a/homeassistant/components/inkbird/sensor.py b/homeassistant/components/inkbird/sensor.py index efda28b110d..c7d80e9bc9f 100644 --- a/homeassistant/components/inkbird/sensor.py +++ b/homeassistant/components/inkbird/sensor.py @@ -4,12 +4,10 @@ from __future__ import annotations from inkbird_ble import DeviceClass, DeviceKey, SensorUpdate, Units -from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, PassiveBluetoothEntityKey, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -19,15 +17,17 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UnitOfPressure, UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info -from .const import DOMAIN +from . import INKBIRDConfigEntry SENSOR_DESCRIPTIONS = { (DeviceClass.TEMPERATURE, Units.TEMP_CELSIUS): SensorEntityDescription( @@ -58,6 +58,18 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + (DeviceClass.CO2, Units.CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription( + key=f"{DeviceClass.CO2}_{Units.CONCENTRATION_PARTS_PER_MILLION}", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + (DeviceClass.PRESSURE, Units.PRESSURE_HPA): SensorEntityDescription( + key=f"{DeviceClass.PRESSURE}_{Units.PRESSURE_HPA}", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.HPA, + state_class=SensorStateClass.MEASUREMENT, + ), } @@ -97,20 +109,17 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: INKBIRDConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the INKBIRD BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( INKBIRDBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload(entry.runtime_data.async_register_processor(processor)) class INKBIRDBluetoothSensorEntity( diff --git a/homeassistant/components/inkbird/strings.json b/homeassistant/components/inkbird/strings.json index 4e12a84b653..b8490dfb92a 100644 --- a/homeassistant/components/inkbird/strings.json +++ b/homeassistant/components/inkbird/strings.json @@ -17,5 +17,10 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "exceptions": { + "no_advertisement": { + "message": "The device with address {address} is not advertising; Make sure it is in range and powered on." + } } } diff --git a/homeassistant/components/intellifire/strings.json b/homeassistant/components/intellifire/strings.json index 423d2c0788d..7f53cb725b5 100644 --- a/homeassistant/components/intellifire/strings.json +++ b/homeassistant/components/intellifire/strings.json @@ -7,7 +7,7 @@ "description": "Select fireplace by serial number:" }, "cloud_api": { - "description": "Authenticate against IntelliFire Cloud", + "description": "Authenticate against IntelliFire cloud", "data_description": { "username": "Your IntelliFire app username", "password": "Your IntelliFire app password" @@ -45,7 +45,7 @@ "name": "Pilot flame error" }, "flame_error": { - "name": "Flame Error" + "name": "Flame error" }, "fan_delay_error": { "name": "Fan delay error" @@ -104,7 +104,7 @@ "name": "Target temperature" }, "fan_speed": { - "name": "Fan Speed" + "name": "Fan speed" }, "timer_end_timestamp": { "name": "Timer end" diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 922fa376903..72853276ab3 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -10,6 +10,11 @@ from aiohttp import web import voluptuous as vol from homeassistant.components import http, sensor +from homeassistant.components.button import ( + DOMAIN as BUTTON_DOMAIN, + SERVICE_PRESS as SERVICE_PRESS_BUTTON, + ButtonDeviceClass, +) from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.cover import ( ATTR_POSITION, @@ -20,6 +25,7 @@ from homeassistant.components.cover import ( CoverDeviceClass, ) from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.input_button import DOMAIN as INPUT_BUTTON_DOMAIN from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, @@ -80,6 +86,7 @@ __all__ = [ ] ONOFF_DEVICE_CLASSES = { + ButtonDeviceClass, CoverDeviceClass, ValveDeviceClass, SwitchDeviceClass, @@ -103,7 +110,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.INTENT_TURN_ON, HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, - description="Turns on/opens a device or entity", + description="Turns on/opens/presses a device or entity. For locks, this performs a 'lock' action. Use for requests like 'turn on', 'activate', 'enable', or 'lock'.", device_classes=ONOFF_DEVICE_CLASSES, ), ) @@ -113,7 +120,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.INTENT_TURN_OFF, HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, - description="Turns off/closes a device or entity", + description="Turns off/closes a device or entity. For locks, this performs an 'unlock' action. Use for requests like 'turn off', 'deactivate', 'disable', or 'unlock'.", device_classes=ONOFF_DEVICE_CLASSES, ), ) @@ -168,6 +175,25 @@ class OnOffIntentHandler(intent.ServiceIntentHandler): """Call service on entity with handling for special cases.""" hass = intent_obj.hass + if state.domain in (BUTTON_DOMAIN, INPUT_BUTTON_DOMAIN): + if service != SERVICE_TURN_ON: + raise intent.IntentHandleError( + f"Entity {state.entity_id} cannot be turned off" + ) + + await self._run_then_background( + hass.async_create_task( + hass.services.async_call( + state.domain, + SERVICE_PRESS_BUTTON, + {ATTR_ENTITY_ID: state.entity_id}, + context=intent_obj.context, + blocking=True, + ) + ) + ) + return + if state.domain == COVER_DOMAIN: # on = open # off = close diff --git a/homeassistant/components/iometer/strings.json b/homeassistant/components/iometer/strings.json index b3878dd1b53..65a962cb42b 100644 --- a/homeassistant/components/iometer/strings.json +++ b/homeassistant/components/iometer/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Setup your IOmeter device for local data", + "description": "Set up your IOmeter device for local data", "data": { "host": "[%key:common::config_flow::data::host%]" }, @@ -21,7 +21,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unknown": "Unexpected error" + "unknown": "[%key:common::config_flow::error::unknown%]" } }, "entity": { diff --git a/homeassistant/components/ipp/strings.json b/homeassistant/components/ipp/strings.json index ac879ef0ab3..b4c092c8ae3 100644 --- a/homeassistant/components/ipp/strings.json +++ b/homeassistant/components/ipp/strings.json @@ -38,7 +38,7 @@ "state": { "printing": "Printing", "idle": "[%key:common::state::idle%]", - "stopped": "Stopped" + "stopped": "[%key:common::state::stopped%]" } }, "uptime": { diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py index 77099e48b41..7a0cf8eaa53 100644 --- a/homeassistant/components/iron_os/__init__.py +++ b/homeassistant/components/iron_os/__init__.py @@ -7,10 +7,8 @@ from typing import TYPE_CHECKING from pynecil import IronOSUpdate, Pynecil -from homeassistant.components import bluetooth -from homeassistant.const import CONF_NAME, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType @@ -35,7 +33,6 @@ PLATFORMS: list[Platform] = [ Platform.UPDATE, ] - CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -60,17 +57,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bo """Set up IronOS from a config entry.""" if TYPE_CHECKING: assert entry.unique_id - ble_device = bluetooth.async_ble_device_from_address( - hass, entry.unique_id, connectable=True - ) - if not ble_device: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="setup_device_unavailable_exception", - translation_placeholders={CONF_NAME: entry.title}, - ) - device = Pynecil(ble_device) + device = Pynecil(entry.unique_id) live_data = IronOSLiveDataCoordinator(hass, entry, device) await live_data.async_config_entry_first_refresh() diff --git a/homeassistant/components/iron_os/config_flow.py b/homeassistant/components/iron_os/config_flow.py index 8509577114f..bb80f088c96 100644 --- a/homeassistant/components/iron_os/config_flow.py +++ b/homeassistant/components/iron_os/config_flow.py @@ -2,9 +2,12 @@ from __future__ import annotations +import logging from typing import Any +from bleak.exc import BleakError from habluetooth import BluetoothServiceInfoBleak +from pynecil import CommunicationError, Pynecil import voluptuous as vol from homeassistant.components.bluetooth.api import async_discovered_service_info @@ -13,6 +16,8 @@ from homeassistant.const import CONF_ADDRESS from .const import DISCOVERY_SVC_UUID, DOMAIN +_LOGGER = logging.getLogger(__name__) + class IronOSConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for IronOS.""" @@ -36,30 +41,62 @@ class IronOSConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm discovery.""" + + errors: dict[str, str] = {} + assert self._discovery_info is not None discovery_info = self._discovery_info title = discovery_info.name if user_input is not None: - return self.async_create_entry(title=title, data={}) + device = Pynecil(discovery_info.address) + try: + await device.connect() + except (CommunicationError, BleakError, TimeoutError): + _LOGGER.debug("Cannot connect:", exc_info=True) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception:") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=title, data={}) + finally: + await device.disconnect() self._set_confirm_only() placeholders = {"name": title} self.context["title_placeholders"] = placeholders return self.async_show_form( - step_id="bluetooth_confirm", description_placeholders=placeholders + step_id="bluetooth_confirm", + description_placeholders=placeholders, + errors=errors, ) async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the user step to pick discovered device.""" + + errors: dict[str, str] = {} + if user_input is not None: address = user_input[CONF_ADDRESS] title = self._discovered_devices[address] await self.async_set_unique_id(address, raise_on_progress=False) self._abort_if_unique_id_configured() - return self.async_create_entry(title=title, data={}) + device = Pynecil(address) + try: + await device.connect() + except (CommunicationError, BleakError, TimeoutError): + _LOGGER.debug("Cannot connect:", exc_info=True) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=title, data={}) + finally: + await device.disconnect() current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, True): @@ -80,4 +117,5 @@ class IronOSConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( {vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)} ), + errors=errors, ) diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index 84c9b895766..99c688ea855 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import timedelta from enum import Enum import logging -from typing import cast +from typing import TYPE_CHECKING, cast from awesomeversion import AwesomeVersion from pynecil import ( @@ -25,6 +25,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.debounce import Debouncer +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -82,10 +84,13 @@ class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): try: self.device_info = await self.device.get_device_info() - except CommunicationError as e: - raise UpdateFailed("Cannot connect to device") from e + except (CommunicationError, TimeoutError): + self.device_info = DeviceInfoResponse() - self.v223_features = AwesomeVersion(self.device_info.build) >= V223 + self.v223_features = ( + self.device_info.build is not None + and AwesomeVersion(self.device_info.build) >= V223 + ) class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]): @@ -96,19 +101,18 @@ class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]): ) -> None: """Initialize IronOS coordinator.""" super().__init__(hass, config_entry, device, SCAN_INTERVAL) + self.device_info = DeviceInfoResponse() async def _async_update_data(self) -> LiveDataResponse: """Fetch data from Device.""" try: - # device info is cached and won't be refetched on every - # coordinator refresh, only after the device has disconnected - # the device info is refetched - self.device_info = await self.device.get_device_info() + await self._update_device_info() return await self.device.get_live_data() - except CommunicationError as e: - raise UpdateFailed("Cannot connect to device") from e + except CommunicationError: + _LOGGER.debug("Cannot connect to device", exc_info=True) + return self.data or LiveDataResponse() @property def has_tip(self) -> bool: @@ -121,6 +125,32 @@ class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]): return self.data.live_temp <= threshold return False + async def _update_device_info(self) -> None: + """Update device info. + + device info is cached and won't be refetched on every + coordinator refresh, only after the device has disconnected + the device info is refetched. + """ + build = self.device_info.build + self.device_info = await self.device.get_device_info() + + if build == self.device_info.build: + return + device_registry = dr.async_get(self.hass) + if TYPE_CHECKING: + assert self.config_entry.unique_id + device = device_registry.async_get_device( + connections={(CONNECTION_BLUETOOTH, self.config_entry.unique_id)} + ) + if device is None: + return + device_registry.async_update_device( + device_id=device.id, + sw_version=self.device_info.build, + serial_number=f"{self.device_info.device_sn} (ID:{self.device_info.device_id})", + ) + class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]): """IronOS coordinator.""" @@ -187,4 +217,7 @@ class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]): try: return await self.github.latest_release() except UpdateException as e: - raise UpdateFailed("Failed to check for latest IronOS update") from e + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_check_failed", + ) from e diff --git a/homeassistant/components/iron_os/entity.py b/homeassistant/components/iron_os/entity.py index 190a9f33639..d07ad5a3aa1 100644 --- a/homeassistant/components/iron_os/entity.py +++ b/homeassistant/components/iron_os/entity.py @@ -37,6 +37,16 @@ class IronOSBaseEntity(CoordinatorEntity[IronOSLiveDataCoordinator]): manufacturer=MANUFACTURER, model=MODEL, name="Pinecil", - sw_version=coordinator.device_info.build, - serial_number=f"{coordinator.device_info.device_sn} (ID:{coordinator.device_info.device_id})", ) + if coordinator.device_info.is_synced: + self._attr_device_info.update( + DeviceInfo( + sw_version=coordinator.device_info.build, + serial_number=f"{coordinator.device_info.device_sn} (ID:{coordinator.device_info.device_id})", + ) + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.device.is_connected diff --git a/homeassistant/components/iron_os/quality_scale.yaml b/homeassistant/components/iron_os/quality_scale.yaml index 8f7eb5ff36a..0a405726231 100644 --- a/homeassistant/components/iron_os/quality_scale.yaml +++ b/homeassistant/components/iron_os/quality_scale.yaml @@ -21,10 +21,10 @@ rules: entity-unique-id: done has-entity-name: done runtime-data: done - test-before-configure: + test-before-configure: done + test-before-setup: status: exempt - comment: Device is set up from a Bluetooth discovery - test-before-setup: done + comment: Device is expected to be disconnected most of the time but will connect quickly when reachable unique-config-entry: done # Silver @@ -47,8 +47,8 @@ rules: devices: done 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. + status: done + comment: Device is not connected to an ip network. FW version in device info is updated. discovery: done docs-data-update: done docs-examples: done diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index ddae9a3020f..8a3d9cc5366 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -20,7 +20,13 @@ }, "abort": { "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } }, "entity": { @@ -115,7 +121,7 @@ "state": { "right_handed": "Right-handed", "left_handed": "Left-handed", - "auto": "Auto" + "auto": "[%key:common::state::auto%]" } }, "animation_speed": { @@ -123,7 +129,7 @@ "state": { "off": "[%key:common::state::off%]", "slow": "[%key:component::iron_os::common::slow%]", - "medium": "Medium", + "medium": "[%key:common::state::medium%]", "fast": "[%key:component::iron_os::common::fast%]" } }, @@ -276,14 +282,14 @@ } }, "exceptions": { - "setup_device_unavailable_exception": { - "message": "Device {name} is not reachable" - }, - "setup_device_connection_error_exception": { - "message": "Connection to device {name} failed, try again later" - }, "submit_setting_failed": { "message": "Failed to submit setting to device, try again later" + }, + "cannot_connect": { + "message": "Cannot connect to device {name}" + }, + "update_check_failed": { + "message": "Failed to check for latest IronOS update" } } } diff --git a/homeassistant/components/iron_os/update.py b/homeassistant/components/iron_os/update.py index 4ec626ffc2a..fba60a8ddaf 100644 --- a/homeassistant/components/iron_os/update.py +++ b/homeassistant/components/iron_os/update.py @@ -3,6 +3,7 @@ from __future__ import annotations from homeassistant.components.update import ( + ATTR_INSTALLED_VERSION, UpdateDeviceClass, UpdateEntity, UpdateEntityDescription, @@ -10,6 +11,7 @@ from homeassistant.components.update import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import IRON_OS_KEY, IronOSConfigEntry, IronOSLiveDataCoordinator from .coordinator import IronOSFirmwareUpdateCoordinator @@ -37,7 +39,7 @@ async def async_setup_entry( ) -class IronOSUpdate(IronOSBaseEntity, UpdateEntity): +class IronOSUpdate(IronOSBaseEntity, UpdateEntity, RestoreEntity): """Representation of an IronOS update entity.""" _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES @@ -56,7 +58,7 @@ class IronOSUpdate(IronOSBaseEntity, UpdateEntity): def installed_version(self) -> str | None: """IronOS version on the device.""" - return self.coordinator.device_info.build + return self.coordinator.device_info.build or self._attr_installed_version @property def title(self) -> str | None: @@ -86,6 +88,9 @@ class IronOSUpdate(IronOSBaseEntity, UpdateEntity): Register extra update listener for the firmware update coordinator. """ + if state := await self.async_get_last_state(): + self._attr_installed_version = state.attributes.get(ATTR_INSTALLED_VERSION) + await super().async_added_to_hass() self.async_on_remove( self.firmware_update.async_add_listener(self._handle_coordinator_update) diff --git a/homeassistant/components/ista_ecotrend/__init__.py b/homeassistant/components/ista_ecotrend/__init__.py index 4262b354acb..e39850d6c51 100644 --- a/homeassistant/components/ista_ecotrend/__init__.py +++ b/homeassistant/components/ista_ecotrend/__init__.py @@ -4,11 +4,11 @@ from __future__ import annotations import logging -from pyecotrend_ista import KeycloakError, LoginError, PyEcotrendIsta, ServerError +from pyecotrend_ista import PyEcotrendIsta +from homeassistant.components.recorder import get_instance from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import DOMAIN from .coordinator import IstaConfigEntry, IstaCoordinator @@ -25,19 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool entry.data[CONF_PASSWORD], _LOGGER, ) - try: - await hass.async_add_executor_job(ista.login) - except ServerError as e: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="connection_exception", - ) from e - except (LoginError, KeycloakError) as e: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="authentication_exception", - translation_placeholders={CONF_EMAIL: entry.data[CONF_EMAIL]}, - ) from e coordinator = IstaCoordinator(hass, entry, ista) await coordinator.async_config_entry_first_refresh() @@ -52,3 +39,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> None: + """Handle removal of an entry.""" + statistic_ids = [f"{DOMAIN}:{name}" for name in entry.options.values()] + get_instance(hass).async_clear_statistics(statistic_ids) diff --git a/homeassistant/components/ista_ecotrend/config_flow.py b/homeassistant/components/ista_ecotrend/config_flow.py index 1a3b2109d0c..ee69e52e580 100644 --- a/homeassistant/components/ista_ecotrend/config_flow.py +++ b/homeassistant/components/ista_ecotrend/config_flow.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any from pyecotrend_ista import KeycloakError, LoginError, PyEcotrendIsta, ServerError 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_EMAIL, CONF_NAME, CONF_PASSWORD from homeassistant.helpers.selector import ( TextSelector, @@ -93,15 +93,30 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN): """Dialog that informs the user that reauth is required.""" errors: dict[str, str] = {} - reauth_entry = self._get_reauth_entry() + reauth_entry = ( + self._get_reauth_entry() + if self.source == SOURCE_REAUTH + else self._get_reconfigure_entry() + ) if user_input is not None: ista = PyEcotrendIsta( user_input[CONF_EMAIL], user_input[CONF_PASSWORD], _LOGGER, ) + + def get_consumption_units() -> set[str]: + ista.login() + consumption_units = ista.get_consumption_unit_details()[ + "consumptionUnits" + ] + return {unit["id"] for unit in consumption_units} + try: - await self.hass.async_add_executor_job(ista.login) + consumption_units = await self.hass.async_add_executor_job( + get_consumption_units + ) + except ServerError: errors["base"] = "cannot_connect" except (LoginError, KeycloakError): @@ -110,10 +125,12 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: + if reauth_entry.unique_id not in consumption_units: + return self.async_abort(reason="unique_id_mismatch") return self.async_update_reload_and_abort(reauth_entry, data=user_input) return self.async_show_form( - step_id="reauth_confirm", + step_id="reauth_confirm" if self.source == SOURCE_REAUTH else "reconfigure", data_schema=self.add_suggested_values_to_schema( data_schema=STEP_USER_DATA_SCHEMA, suggested_values={ @@ -128,3 +145,9 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN): }, errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure flow for ista EcoTrend integration.""" + return await self.async_step_reauth_confirm(user_input) diff --git a/homeassistant/components/ista_ecotrend/coordinator.py b/homeassistant/components/ista_ecotrend/coordinator.py index 53ef4a46d20..13167b9d06c 100644 --- a/homeassistant/components/ista_ecotrend/coordinator.py +++ b/homeassistant/components/ista_ecotrend/coordinator.py @@ -11,7 +11,7 @@ from pyecotrend_ista import KeycloakError, LoginError, PyEcotrendIsta, ServerErr 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 @@ -25,6 +25,7 @@ class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Ista EcoTrend data update coordinator.""" config_entry: IstaConfigEntry + details: dict[str, Any] def __init__( self, hass: HomeAssistant, config_entry: IstaConfigEntry, ista: PyEcotrendIsta @@ -38,22 +39,35 @@ class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]): update_interval=timedelta(days=1), ) self.ista = ista - self.details: dict[str, Any] = {} + + async def _async_setup(self) -> None: + """Set up the ista EcoTrend coordinator.""" + + try: + self.details = await self.hass.async_add_executor_job(self.get_details) + except ServerError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_exception", + ) from e + except (LoginError, KeycloakError) as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_exception", + translation_placeholders={ + CONF_EMAIL: self.config_entry.data[CONF_EMAIL] + }, + ) from e async def _async_update_data(self): """Fetch ista EcoTrend data.""" try: - await self.hass.async_add_executor_job(self.ista.login) - - if not self.details: - self.details = await self.async_get_details() - return await self.hass.async_add_executor_job(self.get_consumption_data) - except ServerError as e: raise UpdateFailed( - "Unable to connect and retrieve data from ista EcoTrend, try again later" + translation_domain=DOMAIN, + translation_key="connection_exception", ) from e except (LoginError, KeycloakError) as e: raise ConfigEntryAuthFailed( @@ -67,17 +81,17 @@ class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]): def get_consumption_data(self) -> dict[str, Any]: """Get raw json data for all consumption units.""" + self.ista.login() return { consumption_unit: self.ista.get_consumption_data(consumption_unit) for consumption_unit in self.ista.get_uuids() } - async def async_get_details(self) -> dict[str, Any]: + def get_details(self) -> dict[str, Any]: """Retrieve details of consumption units.""" - result = await self.hass.async_add_executor_job( - self.ista.get_consumption_unit_details - ) + self.ista.login() + result = self.ista.get_consumption_unit_details() return { consumption_unit: next( diff --git a/homeassistant/components/ista_ecotrend/diagnostics.py b/homeassistant/components/ista_ecotrend/diagnostics.py new file mode 100644 index 00000000000..4c61c197b5e --- /dev/null +++ b/homeassistant/components/ista_ecotrend/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics platform for ista EcoTrend integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import IstaConfigEntry + +TO_REDACT = { + "firstName", + "lastName", + "street", + "houseNumber", + "documentNumber", + "postalCode", + "city", + "propertyNumber", + "idAtCustomerUser", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: IstaConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "details": async_redact_data(config_entry.runtime_data.details, TO_REDACT), + "data": async_redact_data(config_entry.runtime_data.data, TO_REDACT), + } diff --git a/homeassistant/components/ista_ecotrend/quality_scale.yaml b/homeassistant/components/ista_ecotrend/quality_scale.yaml index b942ecba487..a06aef7297f 100644 --- a/homeassistant/components/ista_ecotrend/quality_scale.yaml +++ b/homeassistant/components/ista_ecotrend/quality_scale.yaml @@ -5,12 +5,8 @@ rules: comment: The integration registers no actions. appropriate-polling: done brands: done - common-modules: - status: todo - comment: Group the 3 different executor jobs as one executor job - config-flow-test-coverage: - status: todo - comment: test_form/docstrings outdated, test already_configuret, test abort conditions in reauth, + common-modules: done + config-flow-test-coverage: done config-flow: done dependency-transparency: done docs-actions: @@ -47,7 +43,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: The integration is a web service, there are no discoverable devices. @@ -70,7 +66,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: todo diff --git a/homeassistant/components/ista_ecotrend/strings.json b/homeassistant/components/ista_ecotrend/strings.json index e7c37461b19..389612c40e7 100644 --- a/homeassistant/components/ista_ecotrend/strings.json +++ b/homeassistant/components/ista_ecotrend/strings.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -32,6 +34,18 @@ "email": "[%key:component::ista_ecotrend::config::step::user::data_description::email%]", "password": "[%key:component::ista_ecotrend::config::step::user::data_description::password%]" } + }, + "reconfigure": { + "title": "Update ista EcoTrend configuration", + "description": "Update your credentials if you have changed your **ista EcoTrend** account email or password.", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "[%key:component::ista_ecotrend::config::step::user::data_description::email%]", + "password": "[%key:component::ista_ecotrend::config::step::user::data_description::password%]" + } } } }, diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index e387196ba94..1e227b08206 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -227,9 +227,9 @@ async def async_unload_entry( """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - isy_data = hass.data[DOMAIN][entry.entry_id] + isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] - isy: ISY = isy_data.root + isy = isy_data.root _LOGGER.debug("ISY Stopping Event Stream and automatic updates") isy.websocket.stop() diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py index 1da727fdee8..d170854396c 100644 --- a/homeassistant/components/isy994/entity.py +++ b/homeassistant/components/isy994/entity.py @@ -181,6 +181,7 @@ class ISYProgramEntity(ISYEntity): _actions: Program _status: Program + _node: Program def __init__(self, name: str, status: Program, actions: Program = None) -> None: """Initialize the ISY program-based entity.""" diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index 3686a182fe9..587c0544d6c 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -401,8 +401,7 @@ def _categorize_programs(isy_data: IsyData, programs: Programs) -> None: for dtype, _, node_id in folder.children: if dtype != TAG_FOLDER: continue - entity_folder = folder[node_id] - + entity_folder: Programs = folder[node_id] actions = None status = entity_folder.get_by_name(KEY_STATUS) if not status or status.protocol != PROTO_PROGRAM: diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index eb804d7af09..bbfc7deb80d 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -24,7 +24,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyisy"], - "requirements": ["pyisy==3.1.15"], + "requirements": ["pyisy==3.4.1"], "ssdp": [ { "manufacturer": "Universal Devices Inc.", diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 6546aec6efa..24cfa9aefb1 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -21,6 +21,7 @@ from homeassistant.helpers.service import entity_service_call from homeassistant.helpers.typing import VolDictType from .const import _LOGGER, DOMAIN +from .models import IsyData # Common Services for All Platforms: SERVICE_SEND_PROGRAM_COMMAND = "send_program_command" @@ -149,7 +150,7 @@ def async_setup_services(hass: HomeAssistant) -> None: isy_name = service.data.get(CONF_ISY) for config_entry_id in hass.data[DOMAIN]: - isy_data = hass.data[DOMAIN][config_entry_id] + isy_data: IsyData = hass.data[DOMAIN][config_entry_id] isy = isy_data.root if isy_name and isy_name != isy.conf["name"]: continue diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index 8872226daba..6594c030f08 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -90,7 +90,7 @@ }, "get_zwave_parameter": { "name": "Get Z-Wave Parameter", - "description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", + "description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as an entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", "fields": { "parameter": { "name": "Parameter", @@ -100,7 +100,7 @@ }, "set_zwave_parameter": { "name": "Set Z-Wave parameter", - "description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", + "description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as an entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", "fields": { "parameter": { "name": "[%key:component::isy994::services::get_zwave_parameter::fields::parameter::name%]", diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index 946feddcd10..d5c8a23cbea 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -157,7 +157,7 @@ class ISYEnableSwitchEntity(ISYAuxControlEntity, SwitchEntity): device_info=device_info, ) self._attr_name = description.name # Override super - self._change_handler: EventListener = None + self._change_handler: EventListener | None = None # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/jellyfin/browse_media.py b/homeassistant/components/jellyfin/browse_media.py index e5648b0a34f..9eee4bbb363 100644 --- a/homeassistant/components/jellyfin/browse_media.py +++ b/homeassistant/components/jellyfin/browse_media.py @@ -73,7 +73,7 @@ async def build_root_response( children = [ await item_payload(hass, client, user_id, folder) for folder in folders["Items"] - if folder["CollectionType"] in SUPPORTED_COLLECTION_TYPES + if folder.get("CollectionType") in SUPPORTED_COLLECTION_TYPES ] return BrowseMedia( diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 47d60d74938..282614df7d3 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -131,7 +131,7 @@ async def async_migrate_entry( return {"new_unique_id": new_unique_id} return None - if config_entry.version > 1: + if config_entry.version > 2: # This means the user has downgraded from a future version return False @@ -139,4 +139,9 @@ async def async_migrate_entry( await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) hass.config_entries.async_update_entry(config_entry, version=2) + if config_entry.version == 2: + new_data = {**config_entry.data} + new_data[CONF_LANGUAGE] = config_entry.data[CONF_LANGUAGE][:2] + hass.config_entries.async_update_entry(config_entry, data=new_data, version=3) + return True diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index f33d79a01f5..d8672e8a4a3 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -91,7 +91,6 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): location=self._location, candle_lighting_offset=self._candle_lighting_offset, havdalah_offset=self._havdalah_offset, - language=self._language, ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index 3cec9e9e24e..4572f87a113 100644 --- a/homeassistant/components/jewish_calendar/config_flow.py +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -3,9 +3,10 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, get_args import zoneinfo +from hdate.translator import Language import voluptuous as vol from homeassistant.config_entries import ( @@ -25,8 +26,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.selector import ( BooleanSelector, + LanguageSelector, + LanguageSelectorConfig, LocationSelector, - SelectOptionDict, SelectSelector, SelectSelectorConfig, ) @@ -43,11 +45,6 @@ from .const import ( DOMAIN, ) -LANGUAGE = [ - SelectOptionDict(value="hebrew", label="Hebrew"), - SelectOptionDict(value="english", label="English"), -] - OPTIONS_SCHEMA = vol.Schema( { vol.Optional(CONF_CANDLE_LIGHT_MINUTES, default=DEFAULT_CANDLE_LIGHT): int, @@ -72,8 +69,8 @@ async def _get_data_schema(hass: HomeAssistant) -> vol.Schema: return vol.Schema( { vol.Required(CONF_DIASPORA, default=DEFAULT_DIASPORA): BooleanSelector(), - vol.Required(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): SelectSelector( - SelectSelectorConfig(options=LANGUAGE) + vol.Required(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): LanguageSelector( + LanguageSelectorConfig(languages=list(get_args(Language))) ), vol.Optional(CONF_LOCATION, default=default_location): LocationSelector(), vol.Optional(CONF_ELEVATION, default=hass.config.elevation): int, @@ -87,7 +84,7 @@ async def _get_data_schema(hass: HomeAssistant) -> vol.Schema: class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Jewish calendar.""" - VERSION = 2 + VERSION = 3 @staticmethod @callback diff --git a/homeassistant/components/jewish_calendar/const.py b/homeassistant/components/jewish_calendar/const.py index 0d5455fcd86..3c5b754fee4 100644 --- a/homeassistant/components/jewish_calendar/const.py +++ b/homeassistant/components/jewish_calendar/const.py @@ -2,6 +2,7 @@ DOMAIN = "jewish_calendar" +ATTR_AFTER_SUNSET = "after_sunset" ATTR_DATE = "date" ATTR_NUSACH = "nusach" @@ -13,6 +14,6 @@ DEFAULT_NAME = "Jewish Calendar" DEFAULT_CANDLE_LIGHT = 18 DEFAULT_DIASPORA = False DEFAULT_HAVDALAH_OFFSET_MINUTES = 0 -DEFAULT_LANGUAGE = "english" +DEFAULT_LANGUAGE = "en" SERVICE_COUNT_OMER = "count_omer" diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index 2c031f0d160..b048b0d4bb7 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from hdate import Location -from hdate.translator import Language +from hdate.translator import Language, set_language from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -44,7 +44,7 @@ class JewishCalendarEntity(Entity): ) data = config_entry.runtime_data self._location = data.location - self._language = data.language self._candle_lighting_offset = data.candle_lighting_offset self._havdalah_offset = data.havdalah_offset self._diaspora = data.diaspora + set_language(data.language) diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json index 877c4cf9a99..c93844dd559 100644 --- a/homeassistant/components/jewish_calendar/manifest.json +++ b/homeassistant/components/jewish_calendar/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/jewish_calendar", "iot_class": "calculated", "loggers": ["hdate"], - "requirements": ["hdate[astral]==1.0.3"], + "requirements": ["hdate[astral]==1.1.0"], "single_config_entry": true } diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 7cb281b3af4..f6c1978be21 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -197,6 +197,11 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): super().__init__(config_entry, description) self._attrs: dict[str, str] = {} + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + await self.async_update() + async def async_update(self) -> None: """Update the state of the sensor.""" now = dt_util.now() @@ -213,9 +218,7 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): _LOGGER.debug("Now: %s Sunset: %s", now, sunset) - daytime_date = HDateInfo( - today, diaspora=self._diaspora, language=self._language - ) + daytime_date = HDateInfo(today, diaspora=self._diaspora) # The Jewish day starts after darkness (called "tzais") and finishes at # sunset ("shkia"). The time in between is a gray area @@ -248,7 +251,6 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): location=self._location, candle_lighting_offset=self._candle_lighting_offset, havdalah_offset=self._havdalah_offset, - language=self._language, ) @property @@ -267,7 +269,6 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): # refers to "current" or "upcoming" dates. if self.entity_description.key == "date": hdate = after_shkia_date.hdate - hdate.month.set_language(self._language) self._attrs = { "hebrew_year": str(hdate.year), "hebrew_month_name": str(hdate.month), @@ -285,9 +286,7 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): dict.fromkeys(_holiday.type.name for _holiday in _holidays) ) self._attrs = {"id": _id, "type": _type} - self._attr_options = HolidayDatabase(self._diaspora).get_all_names( - self._language - ) + self._attr_options = HolidayDatabase(self._diaspora).get_all_names() return ", ".join(str(holiday) for holiday in _holidays) if _holidays else "" if self.entity_description.key == "omer_count": return after_shkia_date.omer.total_days if after_shkia_date.omer else 0 diff --git a/homeassistant/components/jewish_calendar/service.py b/homeassistant/components/jewish_calendar/service.py index 7c3c7a21f1c..53d324d6efa 100644 --- a/homeassistant/components/jewish_calendar/service.py +++ b/homeassistant/components/jewish_calendar/service.py @@ -1,14 +1,15 @@ """Services for Jewish Calendar.""" import datetime -from typing import cast +import logging +from typing import get_args from hdate import HebrewDate from hdate.omer import Nusach, Omer -from hdate.translator import Language +from hdate.translator import Language, set_language import voluptuous as vol -from homeassistant.const import CONF_LANGUAGE +from homeassistant.const import CONF_LANGUAGE, SUN_EVENT_SUNSET from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -17,18 +18,21 @@ from homeassistant.core import ( ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import LanguageSelector, LanguageSelectorConfig +from homeassistant.helpers.sun import get_astral_event_date +from homeassistant.util import dt as dt_util -from .const import ATTR_DATE, ATTR_NUSACH, DOMAIN, SERVICE_COUNT_OMER +from .const import ATTR_AFTER_SUNSET, ATTR_DATE, ATTR_NUSACH, DOMAIN, SERVICE_COUNT_OMER -SUPPORTED_LANGUAGES = {"en": "english", "fr": "french", "he": "hebrew"} +_LOGGER = logging.getLogger(__name__) OMER_SCHEMA = vol.Schema( { - vol.Required(ATTR_DATE, default=datetime.date.today): cv.date, + vol.Optional(ATTR_DATE): cv.date, + vol.Optional(ATTR_AFTER_SUNSET, default=True): cv.boolean, vol.Required(ATTR_NUSACH, default="sfarad"): vol.In( [nusach.name.lower() for nusach in Nusach] ), - vol.Required(CONF_LANGUAGE, default="he"): LanguageSelector( - LanguageSelectorConfig(languages=list(SUPPORTED_LANGUAGES.keys())) + vol.Optional(CONF_LANGUAGE, default="he"): LanguageSelector( + LanguageSelectorConfig(languages=list(get_args(Language))) ), } ) @@ -37,16 +41,32 @@ OMER_SCHEMA = vol.Schema( def async_setup_services(hass: HomeAssistant) -> None: """Set up the Jewish Calendar services.""" + def is_after_sunset(hass: HomeAssistant) -> bool: + """Determine if the current time is after sunset.""" + now = dt_util.now() + today = now.date() + event_date = get_astral_event_date(hass, SUN_EVENT_SUNSET, today) + if event_date is None: + _LOGGER.error("Can't get sunset event date for %s", today) + raise ValueError("Can't get sunset event date") + sunset = dt_util.as_local(event_date) + _LOGGER.debug("Now: %s Sunset: %s", now, sunset) + return now > sunset + async def get_omer_count(call: ServiceCall) -> ServiceResponse: """Return the Omer blessing for a given date.""" - hebrew_date = HebrewDate.from_gdate(call.data["date"]) + date = call.data.get("date", dt_util.now().date()) + after_sunset = ( + call.data[ATTR_AFTER_SUNSET] + if "date" in call.data + else is_after_sunset(hass) + ) + hebrew_date = HebrewDate.from_gdate( + date + datetime.timedelta(days=int(after_sunset)) + ) nusach = Nusach[call.data["nusach"].upper()] - - # Currently Omer only supports Hebrew, English, and French and requires - # the full language name - language = cast(Language, SUPPORTED_LANGUAGES[call.data[CONF_LANGUAGE]]) - - omer = Omer(date=hebrew_date, nusach=nusach, language=language) + set_language(call.data[CONF_LANGUAGE]) + omer = Omer(date=hebrew_date, nusach=nusach) return { "message": str(omer.count_str()), "weeks": omer.week, diff --git a/homeassistant/components/jewish_calendar/services.yaml b/homeassistant/components/jewish_calendar/services.yaml index 894fa30fee3..a301857fa66 100644 --- a/homeassistant/components/jewish_calendar/services.yaml +++ b/homeassistant/components/jewish_calendar/services.yaml @@ -1,10 +1,16 @@ count_omer: fields: date: - required: true + required: false example: "2025-04-14" selector: date: + after_sunset: + required: false + example: true + default: true + selector: + boolean: nusach: required: true example: "sfarad" @@ -18,7 +24,7 @@ count_omer: - "adot_mizrah" - "italian" language: - required: true + required: false default: "he" example: "he" selector: diff --git a/homeassistant/components/jewish_calendar/strings.json b/homeassistant/components/jewish_calendar/strings.json index 41e666b1e5d..dcdfb05f10c 100644 --- a/homeassistant/components/jewish_calendar/strings.json +++ b/homeassistant/components/jewish_calendar/strings.json @@ -3,9 +3,9 @@ "sensor": { "hebrew_date": { "state_attributes": { - "hebrew_year": { "name": "Hebrew Year" }, - "hebrew_month_name": { "name": "Hebrew Month Name" }, - "hebrew_day": { "name": "Hebrew Day" } + "hebrew_year": { "name": "Hebrew year" }, + "hebrew_month_name": { "name": "Hebrew month name" }, + "hebrew_day": { "name": "Hebrew day" } } } } @@ -16,10 +16,10 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "diaspora": "Outside of Israel?", - "language": "Language for Holidays and Dates", + "language": "Language for holidays and dates", "location": "[%key:common::config_flow::data::location%]", "elevation": "[%key:common::config_flow::data::elevation%]", - "time_zone": "Time Zone" + "time_zone": "Time zone" }, "data_description": { "time_zone": "If you specify a location, make sure to specify the time zone for correct calendar times calculations" @@ -36,7 +36,7 @@ "init": { "title": "Configure options for Jewish Calendar", "data": { - "candle_lighting_minutes_before_sunset": "Minutes before sunset for candle lighthing", + "candle_lighting_minutes_before_sunset": "Minutes before sunset for candle lighting", "havdalah_minutes_after_sunset": "Minutes after sunset for Havdalah" }, "data_description": { @@ -65,12 +65,16 @@ "name": "Date", "description": "Date to count the Omer for." }, + "after_sunset": { + "name": "After sunset", + "description": "Uses the next Hebrew day (starting at sunset) for a given date. This indicator is ignored if the Date field is empty." + }, "nusach": { "name": "Nusach", "description": "Nusach to count the Omer in." }, "language": { - "name": "Language", + "name": "[%key:common::config_flow::data::language%]", "description": "Language to count the Omer in." } } diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json index c6e5736bd2d..ab17ef6e8ff 100644 --- a/homeassistant/components/jvc_projector/strings.json +++ b/homeassistant/components/jvc_projector/strings.json @@ -56,7 +56,7 @@ "on": "[%key:common::state::on%]", "warming": "Warming", "cooling": "Cooling", - "error": "Error" + "error": "[%key:common::state::error%]" } } } diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 10730d87ed1..737cc2d8b2d 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -315,11 +315,11 @@ "preset_mode": { "name": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::name%]", "state": { - "auto": "Auto", + "auto": "[%key:common::state::auto%]", + "building_protection": "Building protection", "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", - "standby": "Standby", "economy": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", - "building_protection": "Building protection" + "standby": "[%key:common::state::standby%]" } } } diff --git a/homeassistant/components/kodi/browse_media.py b/homeassistant/components/kodi/browse_media.py index 60e99d98cb1..3873f385881 100644 --- a/homeassistant/components/kodi/browse_media.py +++ b/homeassistant/components/kodi/browse_media.py @@ -152,7 +152,10 @@ async def item_payload(item, get_thumbnail_url=None): _LOGGER.debug("Unknown media type received: %s", media_content_type) raise UnknownMediaType from err - thumbnail = item.get("thumbnail") + if "art" in item: + thumbnail = item["art"].get("poster", item.get("thumbnail")) + else: + thumbnail = item.get("thumbnail") if thumbnail is not None and get_thumbnail_url is not None: thumbnail = await get_thumbnail_url( media_content_type, media_content_id, thumbnail_url=thumbnail @@ -237,14 +240,16 @@ async def get_media_info(media_library, search_id, search_type): title = None media = None - properties = ["thumbnail"] + properties = ["thumbnail", "art"] if search_type == MediaType.ALBUM: if search_id: album = await media_library.get_album_details( album_id=int(search_id), properties=properties ) thumbnail = media_library.thumbnail_url( - album["albumdetails"].get("thumbnail") + album["albumdetails"]["art"].get( + "poster", album["albumdetails"].get("thumbnail") + ) ) title = album["albumdetails"]["label"] media = await media_library.get_songs( @@ -256,6 +261,7 @@ async def get_media_info(media_library, search_id, search_type): "album", "thumbnail", "track", + "art", ], ) media = media.get("songs") @@ -274,7 +280,9 @@ async def get_media_info(media_library, search_id, search_type): artist_id=int(search_id), properties=properties ) thumbnail = media_library.thumbnail_url( - artist["artistdetails"].get("thumbnail") + artist["artistdetails"]["art"].get( + "poster", artist["artistdetails"].get("thumbnail") + ) ) title = artist["artistdetails"]["label"] else: @@ -293,9 +301,10 @@ async def get_media_info(media_library, search_id, search_type): movie_id=int(search_id), properties=properties ) thumbnail = media_library.thumbnail_url( - movie["moviedetails"].get("thumbnail") + movie["moviedetails"]["art"].get( + "poster", movie["moviedetails"].get("thumbnail") + ) ) - title = movie["moviedetails"]["label"] else: media = await media_library.get_movies(properties) media = media.get("movies") @@ -305,14 +314,16 @@ async def get_media_info(media_library, search_id, search_type): if search_id: media = await media_library.get_seasons( tv_show_id=int(search_id), - properties=["thumbnail", "season", "tvshowid"], + properties=["thumbnail", "season", "tvshowid", "art"], ) media = media.get("seasons") tvshow = await media_library.get_tv_show_details( tv_show_id=int(search_id), properties=properties ) thumbnail = media_library.thumbnail_url( - tvshow["tvshowdetails"].get("thumbnail") + tvshow["tvshowdetails"]["art"].get( + "poster", tvshow["tvshowdetails"].get("thumbnail") + ) ) title = tvshow["tvshowdetails"]["label"] else: @@ -325,7 +336,7 @@ async def get_media_info(media_library, search_id, search_type): media = await media_library.get_episodes( tv_show_id=int(tv_show_id), season_id=int(season_id), - properties=["thumbnail", "tvshowid", "seasonid"], + properties=["thumbnail", "tvshowid", "seasonid", "art"], ) media = media.get("episodes") if media: @@ -333,7 +344,9 @@ async def get_media_info(media_library, search_id, search_type): season_id=int(media[0]["seasonid"]), properties=properties ) thumbnail = media_library.thumbnail_url( - season["seasondetails"].get("thumbnail") + season["seasondetails"]["art"].get( + "poster", season["seasondetails"].get("thumbnail") + ) ) title = season["seasondetails"]["label"] @@ -343,6 +356,7 @@ async def get_media_info(media_library, search_id, search_type): properties=["thumbnail", "channeltype", "channel", "broadcastnow"], ) media = media.get("channels") + title = "Channels" return thumbnail, title, media diff --git a/homeassistant/components/kulersky/__init__.py b/homeassistant/components/kulersky/__init__.py index 6c8037bdafc..b123a4cc035 100644 --- a/homeassistant/components/kulersky/__init__.py +++ b/homeassistant/components/kulersky/__init__.py @@ -1,21 +1,31 @@ """Kuler Sky lights integration.""" -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +import logging -from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN +from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry +from homeassistant.const import CONF_ADDRESS, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN PLATFORMS = [Platform.LIGHT] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Kuler Sky from a config entry.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - if DATA_ADDRESSES not in hass.data[DOMAIN]: - hass.data[DOMAIN][DATA_ADDRESSES] = set() - + ble_device = async_ble_device_from_address( + hass, entry.data[CONF_ADDRESS], connectable=True + ) + if not ble_device: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -23,11 +33,48 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - # Stop discovery - unregister_discovery = hass.data[DOMAIN].pop(DATA_DISCOVERY_SUBSCRIPTION, None) - if unregister_discovery: - unregister_discovery() - - hass.data.pop(DOMAIN, None) - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + # Version 1 was a single entry instance that started a bluetooth discovery + # thread to add devices. Version 2 has one config entry per device, and + # supports core bluetooth discovery + if config_entry.version == 1: + dev_reg = dr.async_get(hass) + devices = dev_reg.devices.get_devices_for_config_entry_id(config_entry.entry_id) + + if len(devices) == 0: + _LOGGER.error("Unable to migrate; No devices registered") + return False + + first_device = devices[0] + domain_identifiers = [i for i in first_device.identifiers if i[0] == DOMAIN] + address = next(iter(domain_identifiers))[1] + hass.config_entries.async_update_entry( + config_entry, + title=first_device.name or address, + data={CONF_ADDRESS: address}, + unique_id=address, + version=2, + ) + + # Create new config flows for the remaining devices + for device in devices[1:]: + domain_identifiers = [i for i in device.identifiers if i[0] == DOMAIN] + address = next(iter(domain_identifiers))[1] + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={CONF_ADDRESS: address}, + ) + ) + + _LOGGER.debug("Migration to version %s successful", config_entry.version) + + return True diff --git a/homeassistant/components/kulersky/config_flow.py b/homeassistant/components/kulersky/config_flow.py index fca214dd9a3..f27d2ef0ea0 100644 --- a/homeassistant/components/kulersky/config_flow.py +++ b/homeassistant/components/kulersky/config_flow.py @@ -1,26 +1,143 @@ """Config flow for Kuler Sky.""" import logging +from typing import Any +from bluetooth_data_tools import human_readable_name import pykulersky +import voluptuous as vol -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, + async_last_service_info, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS -from .const import DOMAIN +from .const import DOMAIN, EXPECTED_SERVICE_UUID _LOGGER = logging.getLogger(__name__) -async def _async_has_devices(hass: HomeAssistant) -> bool: - """Return if there are devices that can be discovered.""" - # Check if there are any devices that can be discovered in the network. - try: - devices = await pykulersky.discover() - except pykulersky.PykulerskyException as exc: - _LOGGER.error("Unable to discover nearby Kuler Sky devices: %s", exc) - return False - return len(devices) > 0 +class KulerskyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Kulersky.""" + VERSION = 2 -config_entry_flow.register_discovery_flow(DOMAIN, "Kuler Sky", _async_has_devices) + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} + + async def async_step_integration_discovery( + self, discovery_info: dict[str, str] + ) -> ConfigFlowResult: + """Handle the integration discovery step. + + The old version of the integration used to have multiple + device in a single config entry. This is now deprecated. + The integration discovery step is used to create config + entries for each device beyond the first one. + """ + address: str = discovery_info[CONF_ADDRESS] + if service_info := async_last_service_info(self.hass, address): + title = human_readable_name(None, service_info.name, service_info.address) + else: + title = address + await self.async_set_unique_id(address) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=title, + data={CONF_ADDRESS: address}, + ) + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> ConfigFlowResult: + """Handle the bluetooth discovery step.""" + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + self._discovery_info = discovery_info + self.context["title_placeholders"] = { + "name": human_readable_name( + None, discovery_info.name, discovery_info.address + ) + } + return await self.async_step_user() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step to pick discovered device.""" + errors: dict[str, str] = {} + + if user_input is not None: + address = user_input[CONF_ADDRESS] + discovery_info = self._discovered_devices[address] + local_name = human_readable_name( + None, discovery_info.name, discovery_info.address + ) + await self.async_set_unique_id( + discovery_info.address, raise_on_progress=False + ) + self._abort_if_unique_id_configured() + + kulersky_light = None + try: + kulersky_light = pykulersky.Light(discovery_info.address) + await kulersky_light.connect() + except pykulersky.PykulerskyException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=local_name, + data={ + CONF_ADDRESS: discovery_info.address, + }, + ) + finally: + if kulersky_light: + await kulersky_light.disconnect() + + if discovery := self._discovery_info: + self._discovered_devices[discovery.address] = discovery + else: + current_addresses = self._async_current_ids() + for discovery in async_discovered_service_info(self.hass): + if ( + discovery.address in current_addresses + or discovery.address in self._discovered_devices + or EXPECTED_SERVICE_UUID not in discovery.service_uuids + ): + continue + self._discovered_devices[discovery.address] = discovery + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + if self._discovery_info: + data_schema = vol.Schema( + {vol.Required(CONF_ADDRESS): self._discovery_info.address} + ) + else: + data_schema = vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In( + { + service_info.address: ( + f"{service_info.name} ({service_info.address})" + ) + for service_info in self._discovered_devices.values() + } + ), + } + ) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/kulersky/const.py b/homeassistant/components/kulersky/const.py index 8d0b4380bb3..c735b4774f9 100644 --- a/homeassistant/components/kulersky/const.py +++ b/homeassistant/components/kulersky/const.py @@ -4,3 +4,5 @@ DOMAIN = "kulersky" DATA_ADDRESSES = "addresses" DATA_DISCOVERY_SUBSCRIPTION = "discovery_subscription" + +EXPECTED_SERVICE_UUID = "8d96a001-0002-64c2-0001-9acc4838521c" diff --git a/homeassistant/components/kulersky/light.py b/homeassistant/components/kulersky/light.py index bcc3f32dceb..d6a45ed1ebe 100644 --- a/homeassistant/components/kulersky/light.py +++ b/homeassistant/components/kulersky/light.py @@ -2,12 +2,12 @@ from __future__ import annotations -from datetime import timedelta import logging from typing import Any import pykulersky +from homeassistant.components import bluetooth from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_RGBW_COLOR, @@ -15,18 +15,15 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval -from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -DISCOVERY_INTERVAL = timedelta(seconds=60) - async def async_setup_entry( hass: HomeAssistant, @@ -34,32 +31,15 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Kuler sky light devices.""" - - async def discover(*args): - """Attempt to discover new lights.""" - lights = await pykulersky.discover() - - # Filter out already discovered lights - new_lights = [ - light - for light in lights - if light.address not in hass.data[DOMAIN][DATA_ADDRESSES] - ] - - new_entities = [] - for light in new_lights: - hass.data[DOMAIN][DATA_ADDRESSES].add(light.address) - new_entities.append(KulerskyLight(light)) - - async_add_entities(new_entities, update_before_add=True) - - # Start initial discovery - hass.async_create_task(discover()) - - # Perform recurring discovery of new devices - hass.data[DOMAIN][DATA_DISCOVERY_SUBSCRIPTION] = async_track_time_interval( - hass, discover, DISCOVERY_INTERVAL + ble_device = bluetooth.async_ble_device_from_address( + hass, config_entry.data[CONF_ADDRESS], connectable=True ) + entity = KulerskyLight( + config_entry.title, + config_entry.data[CONF_ADDRESS], + pykulersky.Light(ble_device), + ) + async_add_entities([entity], update_before_add=True) class KulerskyLight(LightEntity): @@ -71,37 +51,30 @@ class KulerskyLight(LightEntity): _attr_supported_color_modes = {ColorMode.RGBW} _attr_color_mode = ColorMode.RGBW - def __init__(self, light: pykulersky.Light) -> None: + def __init__(self, name: str, address: str, light: pykulersky.Light) -> None: """Initialize a Kuler Sky light.""" self._light = light - self._attr_unique_id = light.address + self._attr_unique_id = address self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, light.address)}, + identifiers={(DOMAIN, address)}, + connections={(CONNECTION_BLUETOOTH, address)}, manufacturer="Brightech", - name=light.name, + name=name, ) - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - self.async_on_remove( - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, self.async_will_remove_from_hass - ) - ) - - async def async_will_remove_from_hass(self, *args) -> None: + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" try: await self._light.disconnect() except pykulersky.PykulerskyException: _LOGGER.debug( - "Exception disconnected from %s", self._light.address, exc_info=True + "Exception disconnected from %s", self._attr_unique_id, exc_info=True ) @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if light is on.""" - return self.brightness > 0 + return self.brightness is not None and self.brightness > 0 async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" @@ -133,11 +106,13 @@ class KulerskyLight(LightEntity): rgbw = await self._light.get_color() except pykulersky.PykulerskyException as exc: if self._attr_available: - _LOGGER.warning("Unable to connect to %s: %s", self._light.address, exc) + _LOGGER.warning( + "Unable to connect to %s: %s", self._attr_unique_id, exc + ) self._attr_available = False return if self._attr_available is False: - _LOGGER.warning("Reconnected to %s", self._light.address) + _LOGGER.info("Reconnected to %s", self._attr_unique_id) self._attr_available = True brightness = max(rgbw) diff --git a/homeassistant/components/kulersky/manifest.json b/homeassistant/components/kulersky/manifest.json index e0d9ec4fe36..a838c47c698 100644 --- a/homeassistant/components/kulersky/manifest.json +++ b/homeassistant/components/kulersky/manifest.json @@ -1,10 +1,16 @@ { "domain": "kulersky", "name": "Kuler Sky", + "bluetooth": [ + { + "service_uuid": "8d96a001-0002-64c2-0001-9acc4838521c" + } + ], "codeowners": ["@emlove"], "config_flow": true, + "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/kulersky", "iot_class": "local_polling", "loggers": ["bleak", "pykulersky"], - "requirements": ["pykulersky==0.5.2"] + "requirements": ["pykulersky==0.5.8"] } diff --git a/homeassistant/components/kulersky/strings.json b/homeassistant/components/kulersky/strings.json index ad8f0f41ae7..959d7d0690a 100644 --- a/homeassistant/components/kulersky/strings.json +++ b/homeassistant/components/kulersky/strings.json @@ -1,13 +1,23 @@ { "config": { "step": { - "confirm": { - "description": "[%key:common::config_flow::description::confirm_setup%]" + "user": { + "data": { + "address": "[%key:common::config_flow::data::device%]" + } } }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "exceptions": { + "cannot_connect": { + "message": "[%key:common::config_flow::error::cannot_connect%]" } } } diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 25c8fd1091e..ff977438f38 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -1,36 +1,37 @@ """The La Marzocco integration.""" +import asyncio import logging from packaging import version -from pylamarzocco.clients.bluetooth import LaMarzoccoBluetoothClient -from pylamarzocco.clients.cloud import LaMarzoccoCloudClient -from pylamarzocco.clients.local import LaMarzoccoLocalClient -from pylamarzocco.const import BT_MODEL_PREFIXES, FirmwareType -from pylamarzocco.devices.machine import LaMarzoccoMachine +from pylamarzocco import ( + LaMarzoccoBluetoothClient, + LaMarzoccoCloudClient, + LaMarzoccoMachine, +) +from pylamarzocco.const import FirmwareType from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from homeassistant.components.bluetooth import async_discovered_service_info from homeassistant.const import ( - CONF_HOST, CONF_MAC, - CONF_MODEL, - CONF_NAME, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME, Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_USE_BLUETOOTH, DOMAIN from .coordinator import ( LaMarzoccoConfigEntry, LaMarzoccoConfigUpdateCoordinator, - LaMarzoccoFirmwareUpdateCoordinator, LaMarzoccoRuntimeData, + LaMarzoccoScheduleUpdateCoordinator, + LaMarzoccoSettingsUpdateCoordinator, LaMarzoccoStatisticsUpdateCoordinator, ) @@ -45,6 +46,8 @@ PLATFORMS = [ Platform.UPDATE, ] +BT_MODEL_PREFIXES = ("MICRA", "MINI", "GS3") + _LOGGER = logging.getLogger(__name__) @@ -54,38 +57,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - assert entry.unique_id serial = entry.unique_id - client = async_create_clientsession(hass) + client = async_get_clientsession(hass) cloud_client = LaMarzoccoCloudClient( username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], client=client, ) - # initialize the firmware update coordinator early to check the firmware version - firmware_device = LaMarzoccoMachine( - model=entry.data[CONF_MODEL], - serial_number=entry.unique_id, - name=entry.data[CONF_NAME], - cloud_client=cloud_client, - ) + try: + settings = await cloud_client.get_thing_settings(serial) + except AuthFail as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from ex + except (RequestNotSuccessful, TimeoutError) as ex: + _LOGGER.debug(ex, exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, translation_key="api_error" + ) from ex - firmware_coordinator = LaMarzoccoFirmwareUpdateCoordinator( - hass, entry, firmware_device - ) - await firmware_coordinator.async_config_entry_first_refresh() gateway_version = version.parse( - firmware_device.firmware[FirmwareType.GATEWAY].current_version + settings.firmwares[FirmwareType.GATEWAY].build_version ) - if gateway_version >= version.parse("v5.0.9"): - # remove host from config entry, it is not supported anymore - data = {k: v for k, v in entry.data.items() if k != CONF_HOST} - hass.config_entries.async_update_entry( - entry, - data=data, - ) - - elif gateway_version < version.parse("v3.4-rc5"): + if gateway_version < version.parse("v5.0.9"): # incompatible gateway firmware, create an issue ir.async_create_issue( hass, @@ -97,24 +92,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - translation_placeholders={"gateway_version": str(gateway_version)}, ) - # initialize local API - local_client: LaMarzoccoLocalClient | None = None - if (host := entry.data.get(CONF_HOST)) is not None: - _LOGGER.debug("Initializing local API") - local_client = LaMarzoccoLocalClient( - host=host, - local_bearer=entry.data[CONF_TOKEN], - client=client, - ) - # initialize Bluetooth bluetooth_client: LaMarzoccoBluetoothClient | None = None - if entry.options.get(CONF_USE_BLUETOOTH, True): - - def bluetooth_configured() -> bool: - return entry.data.get(CONF_MAC, "") and entry.data.get(CONF_NAME, "") - - if not bluetooth_configured(): + if entry.options.get(CONF_USE_BLUETOOTH, True) and ( + token := settings.ble_auth_token + ): + if CONF_MAC not in entry.data: for discovery_info in async_discovered_service_info(hass): if ( (name := discovery_info.name) @@ -128,38 +111,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - data={ **entry.data, CONF_MAC: discovery_info.address, - CONF_NAME: discovery_info.name, }, ) - break - if bluetooth_configured(): + if not entry.data[CONF_TOKEN]: + # update the token in the config entry + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_TOKEN: token, + }, + ) + + if CONF_MAC in entry.data: _LOGGER.debug("Initializing Bluetooth device") bluetooth_client = LaMarzoccoBluetoothClient( - username=entry.data[CONF_USERNAME], - serial_number=serial, - token=entry.data[CONF_TOKEN], address_or_ble_device=entry.data[CONF_MAC], + ble_token=token, ) device = LaMarzoccoMachine( - model=entry.data[CONF_MODEL], serial_number=entry.unique_id, - name=entry.data[CONF_NAME], cloud_client=cloud_client, - local_client=local_client, bluetooth_client=bluetooth_client, ) coordinators = LaMarzoccoRuntimeData( - LaMarzoccoConfigUpdateCoordinator(hass, entry, device, local_client), - firmware_coordinator, + LaMarzoccoConfigUpdateCoordinator(hass, entry, device), + LaMarzoccoSettingsUpdateCoordinator(hass, entry, device), + LaMarzoccoScheduleUpdateCoordinator(hass, entry, device), LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device), ) - # API does not like concurrent requests, so no asyncio.gather here - await coordinators.config_coordinator.async_config_entry_first_refresh() - await coordinators.statistics_coordinator.async_config_entry_first_refresh() + await asyncio.gather( + coordinators.config_coordinator.async_config_entry_first_refresh(), + coordinators.settings_coordinator.async_config_entry_first_refresh(), + coordinators.schedule_coordinator.async_config_entry_first_refresh(), + coordinators.statistics_coordinator.async_config_entry_first_refresh(), + ) entry.runtime_data = coordinators @@ -184,41 +174,45 @@ async def async_migrate_entry( hass: HomeAssistant, entry: LaMarzoccoConfigEntry ) -> bool: """Migrate config entry.""" - if entry.version > 2: + if entry.version > 3: # guard against downgrade from a future version return False if entry.version == 1: + _LOGGER.error( + "Migration from version 1 is no longer supported, please remove and re-add the integration" + ) + return False + + if entry.version == 2: cloud_client = LaMarzoccoCloudClient( username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], ) try: - fleet = await cloud_client.get_customer_fleet() + things = await cloud_client.list_things() except (AuthFail, RequestNotSuccessful) as exc: _LOGGER.error("Migration failed with error %s", exc) return False - - assert entry.unique_id is not None - device = fleet[entry.unique_id] - v2_data = { + v3_data = { CONF_USERNAME: entry.data[CONF_USERNAME], CONF_PASSWORD: entry.data[CONF_PASSWORD], - CONF_MODEL: device.model, - CONF_NAME: device.name, - CONF_TOKEN: device.communication_key, + CONF_TOKEN: next( + ( + thing.ble_auth_token + for thing in things + if thing.serial_number == entry.unique_id + ), + None, + ), } - - if CONF_HOST in entry.data: - v2_data[CONF_HOST] = entry.data[CONF_HOST] - if CONF_MAC in entry.data: - v2_data[CONF_MAC] = entry.data[CONF_MAC] - + v3_data[CONF_MAC] = entry.data[CONF_MAC] hass.config_entries.async_update_entry( entry, - data=v2_data, - version=2, + data=v3_data, + version=3, ) _LOGGER.debug("Migrated La Marzocco config entry to version 2") + return True diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py index a98cddcda9c..9bf04129095 100644 --- a/homeassistant/components/lamarzocco/binary_sensor.py +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -2,9 +2,11 @@ from collections.abc import Callable from dataclasses import dataclass +from typing import cast -from pylamarzocco.const import MachineModel -from pylamarzocco.models import LaMarzoccoMachineConfig +from pylamarzocco import LaMarzoccoMachine +from pylamarzocco.const import BackFlushStatus, MachineState, WidgetType +from pylamarzocco.models import BackFlush, MachineStatus from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -16,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LaMarzoccoConfigEntry -from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -29,7 +31,7 @@ class LaMarzoccoBinarySensorEntityDescription( ): """Description of a La Marzocco binary sensor.""" - is_on_fn: Callable[[LaMarzoccoMachineConfig], bool | None] + is_on_fn: Callable[[LaMarzoccoMachine], bool | None] ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( @@ -37,33 +39,41 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( key="water_tank", translation_key="water_tank", device_class=BinarySensorDeviceClass.PROBLEM, - is_on_fn=lambda config: not config.water_contact, + is_on_fn=lambda machine: WidgetType.CM_NO_WATER in machine.dashboard.config, entity_category=EntityCategory.DIAGNOSTIC, - supported_fn=lambda coordinator: coordinator.local_connection_configured, ), LaMarzoccoBinarySensorEntityDescription( key="brew_active", translation_key="brew_active", device_class=BinarySensorDeviceClass.RUNNING, - is_on_fn=lambda config: config.brew_active, - available_fn=lambda device: device.websocket_connected, + is_on_fn=( + lambda machine: cast( + MachineStatus, machine.dashboard.config[WidgetType.CM_MACHINE_STATUS] + ).status + is MachineState.BREWING + ), + available_fn=lambda coordinator: not coordinator.websocket_terminated, entity_category=EntityCategory.DIAGNOSTIC, ), LaMarzoccoBinarySensorEntityDescription( key="backflush_enabled", translation_key="backflush_enabled", device_class=BinarySensorDeviceClass.RUNNING, - is_on_fn=lambda config: config.backflush_enabled, + is_on_fn=( + lambda machine: cast( + BackFlush, machine.dashboard.config[WidgetType.CM_BACK_FLUSH] + ).status + is BackFlushStatus.REQUESTED + ), entity_category=EntityCategory.DIAGNOSTIC, ), -) - -SCALE_ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( LaMarzoccoBinarySensorEntityDescription( - key="connected", + key="websocket_connected", + translation_key="websocket_connected", device_class=BinarySensorDeviceClass.CONNECTIVITY, - is_on_fn=lambda config: config.scale.connected if config.scale else None, + is_on_fn=(lambda machine: machine.websocket.connected), entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), ) @@ -76,30 +86,11 @@ async def async_setup_entry( """Set up binary sensor entities.""" coordinator = entry.runtime_data.config_coordinator - entities = [ + async_add_entities( LaMarzoccoBinarySensorEntity(coordinator, description) for description in ENTITIES if description.supported_fn(coordinator) - ] - - if ( - coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) - and coordinator.device.config.scale - ): - entities.extend( - LaMarzoccoScaleBinarySensorEntity(coordinator, description) - for description in SCALE_ENTITIES - ) - - def _async_add_new_scale() -> None: - async_add_entities( - LaMarzoccoScaleBinarySensorEntity(coordinator, description) - for description in SCALE_ENTITIES - ) - - coordinator.new_device_callback.append(_async_add_new_scale) - - async_add_entities(entities) + ) class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity): @@ -110,12 +101,4 @@ class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity): @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - return self.entity_description.is_on_fn(self.coordinator.device.config) - - -class LaMarzoccoScaleBinarySensorEntity( - LaMarzoccoBinarySensorEntity, LaMarzoccScaleEntity -): - """Binary sensor for La Marzocco scales.""" - - entity_description: LaMarzoccoBinarySensorEntityDescription + return self.entity_description.is_on_fn(self.coordinator.device) diff --git a/homeassistant/components/lamarzocco/calendar.py b/homeassistant/components/lamarzocco/calendar.py index 4365bf56b2d..e4673372d0a 100644 --- a/homeassistant/components/lamarzocco/calendar.py +++ b/homeassistant/components/lamarzocco/calendar.py @@ -3,7 +3,7 @@ from collections.abc import Iterator from datetime import datetime, timedelta -from pylamarzocco.models import LaMarzoccoWakeUpSleepEntry +from pylamarzocco.const import WeekDay from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant @@ -18,15 +18,15 @@ PARALLEL_UPDATES = 0 CALENDAR_KEY = "auto_on_off_schedule" -DAY_OF_WEEK = [ - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday", -] +WEEKDAY_TO_ENUM = { + 0: WeekDay.MONDAY, + 1: WeekDay.TUESDAY, + 2: WeekDay.WEDNESDAY, + 3: WeekDay.THURSDAY, + 4: WeekDay.FRIDAY, + 5: WeekDay.SATURDAY, + 6: WeekDay.SUNDAY, +} async def async_setup_entry( @@ -36,10 +36,12 @@ async def async_setup_entry( ) -> None: """Set up switch entities and services.""" - coordinator = entry.runtime_data.config_coordinator + coordinator = entry.runtime_data.schedule_coordinator + async_add_entities( - LaMarzoccoCalendarEntity(coordinator, CALENDAR_KEY, wake_up_sleep_entry) - for wake_up_sleep_entry in coordinator.device.config.wake_up_sleep_entries.values() + LaMarzoccoCalendarEntity(coordinator, CALENDAR_KEY, schedule.identifier) + for schedule in coordinator.device.schedule.smart_wake_up_sleep.schedules + if schedule.identifier ) @@ -52,12 +54,12 @@ class LaMarzoccoCalendarEntity(LaMarzoccoBaseEntity, CalendarEntity): self, coordinator: LaMarzoccoUpdateCoordinator, key: str, - wake_up_sleep_entry: LaMarzoccoWakeUpSleepEntry, + identifier: str, ) -> None: """Set up calendar.""" - super().__init__(coordinator, f"{key}_{wake_up_sleep_entry.entry_id}") - self.wake_up_sleep_entry = wake_up_sleep_entry - self._attr_translation_placeholders = {"id": wake_up_sleep_entry.entry_id} + super().__init__(coordinator, f"{key}_{identifier}") + self._identifier = identifier + self._attr_translation_placeholders = {"id": identifier} @property def event(self) -> CalendarEvent | None: @@ -112,24 +114,31 @@ class LaMarzoccoCalendarEntity(LaMarzoccoBaseEntity, CalendarEntity): def _async_get_calendar_event(self, date: datetime) -> CalendarEvent | None: """Return calendar event for a given weekday.""" + schedule_entry = ( + self.coordinator.device.schedule.smart_wake_up_sleep.schedules_dict[ + self._identifier + ] + ) # check first if auto/on off is turned on in general - if not self.wake_up_sleep_entry.enabled: + if not schedule_entry.enabled: return None # parse the schedule for the day - if DAY_OF_WEEK[date.weekday()] not in self.wake_up_sleep_entry.days: + if WEEKDAY_TO_ENUM[date.weekday()] not in schedule_entry.days: return None - hour_on, minute_on = self.wake_up_sleep_entry.time_on.split(":") - hour_off, minute_off = self.wake_up_sleep_entry.time_off.split(":") + hour_on = schedule_entry.on_time_minutes // 60 + minute_on = schedule_entry.on_time_minutes % 60 + hour_off = schedule_entry.off_time_minutes // 60 + minute_off = schedule_entry.off_time_minutes % 60 - # if off time is 24:00, then it means the off time is the next day - # only for legacy schedules day_offset = 0 - if hour_off == "24": + if hour_off == 24: + # if the machine is scheduled to turn off at midnight, we need to + # set the end date to the next day day_offset = 1 - hour_off = "0" + hour_off = 0 end_date = date.replace( hour=int(hour_off), diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 87a9824423a..8cb2e4dfc61 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -7,10 +7,9 @@ import logging from typing import Any from aiohttp import ClientSession -from pylamarzocco.clients.cloud import LaMarzoccoCloudClient -from pylamarzocco.clients.local import LaMarzoccoLocalClient +from pylamarzocco import LaMarzoccoCloudClient from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful -from pylamarzocco.models import LaMarzoccoDeviceInfo +from pylamarzocco.models import Thing import voluptuous as vol from homeassistant.components.bluetooth import ( @@ -26,9 +25,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import ( CONF_ADDRESS, - CONF_HOST, CONF_MAC, - CONF_MODEL, CONF_NAME, CONF_PASSWORD, CONF_TOKEN, @@ -36,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -52,6 +49,7 @@ from .const import CONF_USE_BLUETOOTH, DOMAIN from .coordinator import LaMarzoccoConfigEntry CONF_MACHINE = "machine" +BT_MODEL_PREFIXES = ("MICRA", "MINI", "GS3") _LOGGER = logging.getLogger(__name__) @@ -59,14 +57,14 @@ _LOGGER = logging.getLogger(__name__) class LmConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for La Marzocco.""" - VERSION = 2 + VERSION = 3 _client: ClientSession def __init__(self) -> None: """Initialize the config flow.""" self._config: dict[str, Any] = {} - self._fleet: dict[str, LaMarzoccoDeviceInfo] = {} + self._things: dict[str, Thing] = {} self._discovered: dict[str, str] = {} async def async_step_user( @@ -83,17 +81,16 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): data = { **data, **user_input, - **self._discovered, } - self._client = async_create_clientsession(self.hass) + self._client = async_get_clientsession(self.hass) cloud_client = LaMarzoccoCloudClient( username=data[CONF_USERNAME], password=data[CONF_PASSWORD], client=self._client, ) try: - self._fleet = await cloud_client.get_customer_fleet() + things = await cloud_client.list_things() except AuthFail: _LOGGER.debug("Server rejected login credentials") errors["base"] = "invalid_auth" @@ -101,37 +98,30 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.error("Error connecting to server: %s", exc) errors["base"] = "cannot_connect" else: - if not self._fleet: + self._things = {thing.serial_number: thing for thing in things} + if not self._things: errors["base"] = "no_machines" if not errors: + self._config = data if self.source == SOURCE_REAUTH: return self.async_update_reload_and_abort( - self._get_reauth_entry(), data=data + self._get_reauth_entry(), data_updates=data ) if self._discovered: - if self._discovered[CONF_MACHINE] not in self._fleet: + if self._discovered[CONF_MACHINE] not in self._things: errors["base"] = "machine_not_found" else: - self._config = data - # if DHCP discovery was used, auto fill machine selection - if CONF_HOST in self._discovered: - return await self.async_step_machine_selection( - user_input={ - CONF_HOST: self._discovered[CONF_HOST], - CONF_MACHINE: self._discovered[CONF_MACHINE], - } - ) - # if Bluetooth discovery was used, only select host - return self.async_show_form( - step_id="machine_selection", - data_schema=vol.Schema( - {vol.Optional(CONF_HOST): cv.string} - ), - ) + # store discovered connection address + if CONF_MAC in self._discovered: + self._config[CONF_MAC] = self._discovered[CONF_MAC] + if CONF_ADDRESS in self._discovered: + self._config[CONF_ADDRESS] = self._discovered[CONF_ADDRESS] + return await self.async_step_machine_selection( + user_input={CONF_MACHINE: self._discovered[CONF_MACHINE]} + ) if not errors: - self._config = data return await self.async_step_machine_selection() placeholders: dict[str, str] | None = None @@ -175,43 +165,35 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): else: serial_number = self._discovered[CONF_MACHINE] - selected_device = self._fleet[serial_number] - - # validate local connection if host is provided - if user_input.get(CONF_HOST): - if not await LaMarzoccoLocalClient.validate_connection( - client=self._client, - host=user_input[CONF_HOST], - token=selected_device.communication_key, - ): - errors[CONF_HOST] = "cannot_connect" - else: - self._config[CONF_HOST] = user_input[CONF_HOST] + selected_device = self._things[serial_number] if not errors: if self.source == SOURCE_RECONFIGURE: for service_info in async_discovered_service_info(self.hass): - self._discovered[service_info.name] = service_info.address + if service_info.name.startswith(BT_MODEL_PREFIXES): + self._discovered[service_info.name] = service_info.address if self._discovered: return await self.async_step_bluetooth_selection() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=self._config, + ) return self.async_create_entry( title=selected_device.name, data={ **self._config, - CONF_NAME: selected_device.name, - CONF_MODEL: selected_device.model, - CONF_TOKEN: selected_device.communication_key, + CONF_TOKEN: self._things[serial_number].ble_auth_token, }, ) machine_options = [ SelectOptionDict( - value=device.serial_number, - label=f"{device.model} ({device.serial_number})", + value=thing.serial_number, + label=f"{thing.name} ({thing.serial_number})", ) - for device in self._fleet.values() + for thing in self._things.values() ] machine_selection_schema = vol.Schema( @@ -224,7 +206,6 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): mode=SelectSelectorMode.DROPDOWN, ) ), - vol.Optional(CONF_HOST): cv.string, } ) @@ -242,8 +223,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: return self.async_update_reload_and_abort( self._get_reconfigure_entry(), - data={ - **self._config, + data_updates={ CONF_MAC: user_input[CONF_MAC], }, ) @@ -304,7 +284,6 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(serial) self._abort_if_unique_id_configured( updates={ - CONF_HOST: discovery_info.ip, CONF_ADDRESS: discovery_info.macaddress, } ) @@ -316,8 +295,8 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): discovery_info.ip, ) + self._discovered[CONF_NAME] = discovery_info.hostname self._discovered[CONF_MACHINE] = serial - self._discovered[CONF_HOST] = discovery_info.ip self._discovered[CONF_ADDRESS] = discovery_info.macaddress return await self.async_step_user() diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index dddca6565e4..f0f64e02c28 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -3,28 +3,26 @@ from __future__ import annotations from abc import abstractmethod -from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta import logging from typing import Any -from pylamarzocco.clients.local import LaMarzoccoLocalClient -from pylamarzocco.devices.machine import LaMarzoccoMachine +from pylamarzocco import LaMarzoccoMachine from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN -SCAN_INTERVAL = timedelta(seconds=30) -FIRMWARE_UPDATE_INTERVAL = timedelta(hours=1) -STATISTICS_UPDATE_INTERVAL = timedelta(minutes=5) +SCAN_INTERVAL = timedelta(seconds=15) +SETTINGS_UPDATE_INTERVAL = timedelta(hours=1) +SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=5) +STATISTICS_UPDATE_INTERVAL = timedelta(minutes=15) _LOGGER = logging.getLogger(__name__) @@ -33,7 +31,8 @@ class LaMarzoccoRuntimeData: """Runtime data for La Marzocco.""" config_coordinator: LaMarzoccoConfigUpdateCoordinator - firmware_coordinator: LaMarzoccoFirmwareUpdateCoordinator + settings_coordinator: LaMarzoccoSettingsUpdateCoordinator + schedule_coordinator: LaMarzoccoScheduleUpdateCoordinator statistics_coordinator: LaMarzoccoStatisticsUpdateCoordinator @@ -45,13 +44,13 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): _default_update_interval = SCAN_INTERVAL config_entry: LaMarzoccoConfigEntry + websocket_terminated = True def __init__( self, hass: HomeAssistant, entry: LaMarzoccoConfigEntry, device: LaMarzoccoMachine, - local_client: LaMarzoccoLocalClient | None = None, ) -> None: """Initialize coordinator.""" super().__init__( @@ -62,9 +61,6 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): update_interval=self._default_update_interval, ) self.device = device - self.local_connection_configured = local_client is not None - self._local_client = local_client - self.new_device_callback: list[Callable] = [] async def _async_update_data(self) -> None: """Do the data update.""" @@ -89,73 +85,66 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator): """Class to handle fetching data from the La Marzocco API centrally.""" - _scale_address: str | None = None + async def _internal_async_update_data(self) -> None: + """Fetch data from API endpoint.""" - async def _async_connect_websocket(self) -> None: - """Set up the coordinator.""" - if self._local_client is not None and ( - self._local_client.websocket is None or self._local_client.websocket.closed - ): - _LOGGER.debug("Init WebSocket in background task") + if self.device.websocket.connected: + return + await self.device.get_dashboard() + _LOGGER.debug("Current status: %s", self.device.dashboard.to_dict()) - self.config_entry.async_create_background_task( - hass=self.hass, - target=self.device.websocket_connect( - notify_callback=lambda: self.async_set_updated_data(None) - ), - name="lm_websocket_task", - ) + self.config_entry.async_create_background_task( + hass=self.hass, + target=self.connect_websocket(), + name="lm_websocket_task", + ) - async def websocket_close(_: Any | None = None) -> None: - if ( - self._local_client is not None - and self._local_client.websocket is not None - and not self._local_client.websocket.closed - ): - await self._local_client.websocket.close() + async def websocket_close(_: Any | None = None) -> None: + await self.device.websocket.disconnect() - self.config_entry.async_on_unload( - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, websocket_close - ) - ) - self.config_entry.async_on_unload(websocket_close) + self.config_entry.async_on_unload( + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, websocket_close) + ) + self.config_entry.async_on_unload(websocket_close) + + async def connect_websocket(self) -> None: + """Connect to the websocket.""" + + _LOGGER.debug("Init WebSocket in background task") + + self.websocket_terminated = False + self.async_update_listeners() + + await self.device.connect_dashboard_websocket( + update_callback=lambda _: self.async_set_updated_data(None), + connect_callback=self.async_update_listeners, + disconnect_callback=self.async_update_listeners, + ) + + self.websocket_terminated = True + self.async_update_listeners() + + +class LaMarzoccoSettingsUpdateCoordinator(LaMarzoccoUpdateCoordinator): + """Coordinator for La Marzocco settings.""" + + _default_update_interval = SETTINGS_UPDATE_INTERVAL async def _internal_async_update_data(self) -> None: """Fetch data from API endpoint.""" - await self.device.get_config() - _LOGGER.debug("Current status: %s", str(self.device.config)) - await self._async_connect_websocket() - self._async_add_remove_scale() - - @callback - def _async_add_remove_scale(self) -> None: - """Add or remove a scale when added or removed.""" - if self.device.config.scale and not self._scale_address: - self._scale_address = self.device.config.scale.address - for scale_callback in self.new_device_callback: - scale_callback() - elif not self.device.config.scale and self._scale_address: - device_registry = dr.async_get(self.hass) - if device := device_registry.async_get_device( - identifiers={(DOMAIN, self._scale_address)} - ): - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) - self._scale_address = None + await self.device.get_settings() + _LOGGER.debug("Current settings: %s", self.device.settings.to_dict()) -class LaMarzoccoFirmwareUpdateCoordinator(LaMarzoccoUpdateCoordinator): - """Coordinator for La Marzocco firmware.""" +class LaMarzoccoScheduleUpdateCoordinator(LaMarzoccoUpdateCoordinator): + """Coordinator for La Marzocco schedule.""" - _default_update_interval = FIRMWARE_UPDATE_INTERVAL + _default_update_interval = SCHEDULE_UPDATE_INTERVAL async def _internal_async_update_data(self) -> None: """Fetch data from API endpoint.""" - await self.device.get_firmware() - _LOGGER.debug("Current firmware: %s", str(self.device.firmware)) + await self.device.get_schedule() + _LOGGER.debug("Current schedule: %s", self.device.schedule.to_dict()) class LaMarzoccoStatisticsUpdateCoordinator(LaMarzoccoUpdateCoordinator): @@ -165,5 +154,5 @@ class LaMarzoccoStatisticsUpdateCoordinator(LaMarzoccoUpdateCoordinator): async def _internal_async_update_data(self) -> None: """Fetch data from API endpoint.""" - await self.device.get_statistics() - _LOGGER.debug("Current statistics: %s", str(self.device.statistics)) + await self.device.get_coffee_and_flush_counter() + _LOGGER.debug("Current statistics: %s", self.device.statistics.to_dict()) diff --git a/homeassistant/components/lamarzocco/diagnostics.py b/homeassistant/components/lamarzocco/diagnostics.py index 204a8b7142a..7743523e01d 100644 --- a/homeassistant/components/lamarzocco/diagnostics.py +++ b/homeassistant/components/lamarzocco/diagnostics.py @@ -2,14 +2,13 @@ from __future__ import annotations -from dataclasses import asdict -from typing import Any, TypedDict - -from pylamarzocco.const import FirmwareType +from typing import Any from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_MAC, CONF_TOKEN from homeassistant.core import HomeAssistant +from .const import CONF_USE_BLUETOOTH from .coordinator import LaMarzoccoConfigEntry TO_REDACT = { @@ -17,15 +16,6 @@ TO_REDACT = { } -class DiagnosticsData(TypedDict): - """Diagnostic data for La Marzocco.""" - - model: str - config: dict[str, Any] - firmware: list[dict[FirmwareType, dict[str, Any]]] - statistics: dict[str, Any] - - async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: LaMarzoccoConfigEntry, @@ -33,12 +23,12 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator = entry.runtime_data.config_coordinator device = coordinator.device - # collect all data sources - diagnostics_data = DiagnosticsData( - model=device.model, - config=asdict(device.config), - firmware=[{key: asdict(firmware)} for key, firmware in device.firmware.items()], - statistics=asdict(device.statistics), - ) - - return async_redact_data(diagnostics_data, TO_REDACT) + data = { + "device": device.to_dict(), + "bluetooth_available": { + "options_enabled": entry.options.get(CONF_USE_BLUETOOTH, True), + CONF_MAC: CONF_MAC in entry.data, + CONF_TOKEN: CONF_TOKEN in entry.data, + }, + } + return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index 3e70ff1acdf..6dc024645ce 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -2,10 +2,8 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING from pylamarzocco.const import FirmwareType -from pylamarzocco.devices.machine import LaMarzoccoMachine from homeassistant.const import CONF_ADDRESS, CONF_MAC from homeassistant.helpers.device_registry import ( @@ -24,7 +22,7 @@ from .coordinator import LaMarzoccoUpdateCoordinator class LaMarzoccoEntityDescription(EntityDescription): """Description for all LM entities.""" - available_fn: Callable[[LaMarzoccoMachine], bool] = lambda _: True + available_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True supported_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True @@ -46,12 +44,12 @@ class LaMarzoccoBaseEntity( self._attr_unique_id = f"{device.serial_number}_{key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device.serial_number)}, - name=device.name, + name=device.dashboard.name, manufacturer="La Marzocco", - model=device.full_model_name, - model_id=device.model, + model=device.dashboard.model_name.value, + model_id=device.dashboard.model_code.value, serial_number=device.serial_number, - sw_version=device.firmware[FirmwareType.MACHINE].current_version, + sw_version=device.settings.firmwares[FirmwareType.MACHINE].build_version, ) connections: set[tuple[str, str]] = set() if coordinator.config_entry.data.get(CONF_ADDRESS): @@ -75,7 +73,7 @@ class LaMarzoccoEntity(LaMarzoccoBaseEntity): def available(self) -> bool: """Return True if entity is available.""" if super().available: - return self.entity_description.available_fn(self.coordinator.device) + return self.entity_description.available_fn(self.coordinator) return False def __init__( @@ -86,26 +84,3 @@ class LaMarzoccoEntity(LaMarzoccoBaseEntity): """Initialize the entity.""" super().__init__(coordinator, entity_description.key) self.entity_description = entity_description - - -class LaMarzoccScaleEntity(LaMarzoccoEntity): - """Common class for scale.""" - - def __init__( - self, - coordinator: LaMarzoccoUpdateCoordinator, - entity_description: LaMarzoccoEntityDescription, - ) -> None: - """Initialize the entity.""" - super().__init__(coordinator, entity_description) - scale = coordinator.device.config.scale - if TYPE_CHECKING: - assert scale - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, scale.address)}, - name=scale.name, - manufacturer="Acaia", - model="Lunar", - model_id="Y.301", - via_device=(DOMAIN, coordinator.device.serial_number), - ) diff --git a/homeassistant/components/lamarzocco/icons.json b/homeassistant/components/lamarzocco/icons.json index 2be882fafea..a319384d7fd 100644 --- a/homeassistant/components/lamarzocco/icons.json +++ b/homeassistant/components/lamarzocco/icons.json @@ -34,36 +34,20 @@ "dose": { "default": "mdi:cup-water" }, - "prebrew_off": { - "default": "mdi:water-off" - }, - "prebrew_on": { - "default": "mdi:water" - }, - "preinfusion_off": { - "default": "mdi:water" - }, - "scale_target": { - "default": "mdi:scale-balance" - }, "smart_standby_time": { "default": "mdi:timer" }, - "steam_temp": { - "default": "mdi:thermometer-water" + "preinfusion_time": { + "default": "mdi:water" }, - "tea_water_duration": { - "default": "mdi:timer-sand" + "prebrew_time_on": { + "default": "mdi:water" + }, + "prebrew_time_off": { + "default": "mdi:water-off" } }, "select": { - "active_bbw": { - "default": "mdi:alpha-u", - "state": { - "a": "mdi:alpha-a", - "b": "mdi:alpha-b" - } - }, "smart_standby_mode": { "default": "mdi:power", "state": { @@ -89,23 +73,20 @@ } }, "sensor": { - "drink_stats_coffee": { - "default": "mdi:chart-line" + "coffee_boiler_ready_time": { + "default": "mdi:av-timer" }, - "drink_stats_flushing": { - "default": "mdi:chart-line" + "last_cleaning_time": { + "default": "mdi:spray-bottle" }, - "drink_stats_coffee_key": { - "default": "mdi:chart-scatter-plot" + "steam_boiler_ready_time": { + "default": "mdi:av-timer" }, - "shot_timer": { - "default": "mdi:timer" + "total_coffees_made": { + "default": "mdi:coffee" }, - "current_temp_coffee": { - "default": "mdi:thermometer" - }, - "current_temp_steam": { - "default": "mdi:thermometer" + "total_flushes_done": { + "default": "mdi:water-pump" } }, "switch": { diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 73f00b2bdd0..572f70bc455 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -34,8 +34,8 @@ ], "documentation": "https://www.home-assistant.io/integrations/lamarzocco", "integration_type": "device", - "iot_class": "cloud_polling", + "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==1.4.9"] + "requirements": ["pylamarzocco==2.0.0"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 08e9ad7e590..7c4fe33a041 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -2,18 +2,12 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Any +from typing import Any, cast -from pylamarzocco.const import ( - KEYS_PER_MODEL, - BoilerType, - MachineModel, - PhysicalKey, - PrebrewMode, -) -from pylamarzocco.devices.machine import LaMarzoccoMachine +from pylamarzocco import LaMarzoccoMachine +from pylamarzocco.const import ModelName, PreExtractionMode, WidgetType from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.models import LaMarzoccoMachineConfig +from pylamarzocco.models import CoffeeBoiler, PreBrewing from homeassistant.components.number import ( NumberDeviceClass, @@ -32,8 +26,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN -from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator -from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity +from .coordinator import LaMarzoccoConfigEntry +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription PARALLEL_UPDATES = 1 @@ -45,25 +39,10 @@ class LaMarzoccoNumberEntityDescription( ): """Description of a La Marzocco number entity.""" - native_value_fn: Callable[[LaMarzoccoMachineConfig], float | int] + native_value_fn: Callable[[LaMarzoccoMachine], float | int] set_value_fn: Callable[[LaMarzoccoMachine, float | int], Coroutine[Any, Any, bool]] -@dataclass(frozen=True, kw_only=True) -class LaMarzoccoKeyNumberEntityDescription( - LaMarzoccoEntityDescription, - NumberEntityDescription, -): - """Description of an La Marzocco number entity with keys.""" - - native_value_fn: Callable[ - [LaMarzoccoMachineConfig, PhysicalKey], float | int | None - ] - set_value_fn: Callable[ - [LaMarzoccoMachine, float | int, PhysicalKey], Coroutine[Any, Any, bool] - ] - - ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( LaMarzoccoNumberEntityDescription( key="coffee_temp", @@ -73,43 +52,11 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( native_step=PRECISION_TENTHS, native_min_value=85, native_max_value=104, - set_value_fn=lambda machine, temp: machine.set_temp(BoilerType.COFFEE, temp), - native_value_fn=lambda config: config.boilers[ - BoilerType.COFFEE - ].target_temperature, - ), - LaMarzoccoNumberEntityDescription( - key="steam_temp", - translation_key="steam_temp", - device_class=NumberDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - native_step=PRECISION_WHOLE, - native_min_value=126, - native_max_value=131, - set_value_fn=lambda machine, temp: machine.set_temp(BoilerType.STEAM, temp), - native_value_fn=lambda config: config.boilers[ - BoilerType.STEAM - ].target_temperature, - supported_fn=lambda coordinator: coordinator.device.model - in ( - MachineModel.GS3_AV, - MachineModel.GS3_MP, - ), - ), - LaMarzoccoNumberEntityDescription( - key="tea_water_duration", - translation_key="tea_water_duration", - device_class=NumberDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.SECONDS, - native_step=PRECISION_WHOLE, - native_min_value=0, - native_max_value=30, - set_value_fn=lambda machine, value: machine.set_dose_tea_water(int(value)), - native_value_fn=lambda config: config.dose_hot_water, - supported_fn=lambda coordinator: coordinator.device.model - in ( - MachineModel.GS3_AV, - MachineModel.GS3_MP, + set_value_fn=lambda machine, temp: machine.set_coffee_target_temperature(temp), + native_value_fn=( + lambda machine: cast( + CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER] + ).target_temperature ), ), LaMarzoccoNumberEntityDescription( @@ -117,118 +64,136 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( translation_key="smart_standby_time", device_class=NumberDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.MINUTES, - native_step=10, - native_min_value=10, - native_max_value=240, - entity_category=EntityCategory.CONFIG, - set_value_fn=lambda machine, value: machine.set_smart_standby( - enabled=machine.config.smart_standby.enabled, - mode=machine.config.smart_standby.mode, - minutes=int(value), - ), - native_value_fn=lambda config: config.smart_standby.minutes, - ), -) - - -KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( - LaMarzoccoKeyNumberEntityDescription( - key="prebrew_off", - translation_key="prebrew_off", - device_class=NumberDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.SECONDS, - native_step=PRECISION_TENTHS, - native_min_value=1, - native_max_value=10, - entity_category=EntityCategory.CONFIG, - set_value_fn=lambda machine, value, key: machine.set_prebrew_time( - prebrew_off_time=value, key=key - ), - native_value_fn=lambda config, key: config.prebrew_configuration[key][ - 0 - ].off_time, - available_fn=lambda device: len(device.config.prebrew_configuration) > 0 - and device.config.prebrew_mode - in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED), - supported_fn=lambda coordinator: coordinator.device.model - != MachineModel.GS3_MP, - ), - LaMarzoccoKeyNumberEntityDescription( - key="prebrew_on", - translation_key="prebrew_on", - device_class=NumberDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.SECONDS, - native_step=PRECISION_TENTHS, - native_min_value=2, - native_max_value=10, - entity_category=EntityCategory.CONFIG, - set_value_fn=lambda machine, value, key: machine.set_prebrew_time( - prebrew_on_time=value, key=key - ), - native_value_fn=lambda config, key: config.prebrew_configuration[key][ - 0 - ].off_time, - available_fn=lambda device: len(device.config.prebrew_configuration) > 0 - and device.config.prebrew_mode - in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED), - supported_fn=lambda coordinator: coordinator.device.model - != MachineModel.GS3_MP, - ), - LaMarzoccoKeyNumberEntityDescription( - key="preinfusion_off", - translation_key="preinfusion_off", - device_class=NumberDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.SECONDS, - native_step=PRECISION_TENTHS, - native_min_value=2, - native_max_value=29, - entity_category=EntityCategory.CONFIG, - set_value_fn=lambda machine, value, key: machine.set_preinfusion_time( - preinfusion_time=value, key=key - ), - native_value_fn=lambda config, key: config.prebrew_configuration[key][ - 1 - ].preinfusion_time, - available_fn=lambda device: len(device.config.prebrew_configuration) > 0 - and device.config.prebrew_mode == PrebrewMode.PREINFUSION, - supported_fn=lambda coordinator: coordinator.device.model - != MachineModel.GS3_MP, - ), - LaMarzoccoKeyNumberEntityDescription( - key="dose", - translation_key="dose", - native_unit_of_measurement="ticks", native_step=PRECISION_WHOLE, native_min_value=0, - native_max_value=999, + native_max_value=240, entity_category=EntityCategory.CONFIG, - set_value_fn=lambda machine, ticks, key: machine.set_dose( - dose=int(ticks), key=key + set_value_fn=( + lambda machine, value: machine.set_smart_standby( + enabled=machine.schedule.smart_wake_up_sleep.smart_stand_by_enabled, + mode=machine.schedule.smart_wake_up_sleep.smart_stand_by_after, + minutes=int(value), + ) ), - native_value_fn=lambda config, key: config.doses[key], - supported_fn=lambda coordinator: coordinator.device.model - == MachineModel.GS3_AV, + native_value_fn=lambda machine: machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes, ), -) - -SCALE_KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( - LaMarzoccoKeyNumberEntityDescription( - key="scale_target", - translation_key="scale_target", - native_step=PRECISION_WHOLE, - native_min_value=1, - native_max_value=100, + LaMarzoccoNumberEntityDescription( + key="preinfusion_off", + translation_key="preinfusion_time", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_step=PRECISION_TENTHS, + native_min_value=0, + native_max_value=10, entity_category=EntityCategory.CONFIG, - set_value_fn=lambda machine, weight, key: machine.set_bbw_recipe_target( - key, int(weight) + set_value_fn=( + lambda machine, value: machine.set_pre_extraction_times( + seconds_on=0, + seconds_off=float(value), + ) ), - native_value_fn=lambda config, key: ( - config.bbw_settings.doses[key] if config.bbw_settings else None + native_value_fn=( + lambda machine: cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ) + .times.pre_infusion[0] + .seconds.seconds_out + ), + available_fn=( + lambda coordinator: cast( + PreBrewing, + coordinator.device.dashboard.config[WidgetType.CM_PRE_BREWING], + ).mode + is PreExtractionMode.PREINFUSION ), supported_fn=( - lambda coordinator: coordinator.device.model - in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) - and coordinator.device.config.scale is not None + lambda coordinator: coordinator.device.dashboard.model_name + in ( + ModelName.LINEA_MICRA, + ModelName.LINEA_MINI, + ModelName.LINEA_MINI_R, + ) + ), + ), + LaMarzoccoNumberEntityDescription( + key="prebrew_on", + translation_key="prebrew_time_on", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + native_step=PRECISION_TENTHS, + native_min_value=0, + native_max_value=10, + entity_category=EntityCategory.CONFIG, + set_value_fn=( + lambda machine, value: machine.set_pre_extraction_times( + seconds_on=float(value), + seconds_off=cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ) + .times.pre_brewing[0] + .seconds.seconds_out, + ) + ), + native_value_fn=( + lambda machine: cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ) + .times.pre_brewing[0] + .seconds.seconds_in + ), + available_fn=lambda coordinator: cast( + PreBrewing, coordinator.device.dashboard.config[WidgetType.CM_PRE_BREWING] + ).mode + is PreExtractionMode.PREBREWING, + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in ( + ModelName.LINEA_MICRA, + ModelName.LINEA_MINI, + ModelName.LINEA_MINI_R, + ) + ), + ), + LaMarzoccoNumberEntityDescription( + key="prebrew_off", + translation_key="prebrew_time_off", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + native_step=PRECISION_TENTHS, + native_min_value=0, + native_max_value=10, + entity_category=EntityCategory.CONFIG, + set_value_fn=( + lambda machine, value: machine.set_pre_extraction_times( + seconds_on=cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ) + .times.pre_brewing[0] + .seconds.seconds_in, + seconds_off=float(value), + ) + ), + native_value_fn=( + lambda machine: cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ) + .times.pre_brewing[0] + .seconds.seconds_out + ), + available_fn=( + lambda coordinator: cast( + PreBrewing, + coordinator.device.dashboard.config[WidgetType.CM_PRE_BREWING], + ).mode + is PreExtractionMode.PREBREWING + ), + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in ( + ModelName.LINEA_MICRA, + ModelName.LINEA_MINI, + ModelName.LINEA_MINI_R, + ) ), ), ) @@ -247,34 +212,6 @@ async def async_setup_entry( if description.supported_fn(coordinator) ] - for description in KEY_ENTITIES: - if description.supported_fn(coordinator): - num_keys = KEYS_PER_MODEL[MachineModel(coordinator.device.model)] - entities.extend( - LaMarzoccoKeyNumberEntity(coordinator, description, key) - for key in range(min(num_keys, 1), num_keys + 1) - ) - - for description in SCALE_KEY_ENTITIES: - if description.supported_fn(coordinator): - if bbw_settings := coordinator.device.config.bbw_settings: - entities.extend( - LaMarzoccoScaleTargetNumberEntity( - coordinator, description, int(key) - ) - for key in bbw_settings.doses - ) - - def _async_add_new_scale() -> None: - if bbw_settings := coordinator.device.config.bbw_settings: - async_add_entities( - LaMarzoccoScaleTargetNumberEntity(coordinator, description, int(key)) - for description in SCALE_KEY_ENTITIES - for key in bbw_settings.doses - ) - - coordinator.new_device_callback.append(_async_add_new_scale) - async_add_entities(entities) @@ -286,7 +223,7 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity): @property def native_value(self) -> float: """Return the current value.""" - return self.entity_description.native_value_fn(self.coordinator.device.config) + return self.entity_description.native_value_fn(self.coordinator.device) async def async_set_native_value(self, value: float) -> None: """Set the value.""" @@ -305,62 +242,3 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity): }, ) from exc self.async_write_ha_state() - - -class LaMarzoccoKeyNumberEntity(LaMarzoccoEntity, NumberEntity): - """Number representing espresso machine with key support.""" - - entity_description: LaMarzoccoKeyNumberEntityDescription - - def __init__( - self, - coordinator: LaMarzoccoUpdateCoordinator, - description: LaMarzoccoKeyNumberEntityDescription, - pyhsical_key: int, - ) -> None: - """Initialize the entity.""" - super().__init__(coordinator, description) - - # Physical Key on the machine the entity represents. - if pyhsical_key == 0: - pyhsical_key = 1 - else: - self._attr_translation_key = f"{description.translation_key}_key" - self._attr_translation_placeholders = {"key": str(pyhsical_key)} - self._attr_unique_id = f"{super()._attr_unique_id}_key{pyhsical_key}" - self._attr_entity_registry_enabled_default = False - self.pyhsical_key = pyhsical_key - - @property - def native_value(self) -> float | None: - """Return the current value.""" - return self.entity_description.native_value_fn( - self.coordinator.device.config, PhysicalKey(self.pyhsical_key) - ) - - async def async_set_native_value(self, value: float) -> None: - """Set the value.""" - if value != self.native_value: - try: - await self.entity_description.set_value_fn( - self.coordinator.device, value, PhysicalKey(self.pyhsical_key) - ) - except RequestNotSuccessful as exc: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="number_exception_key", - translation_placeholders={ - "key": self.entity_description.key, - "value": str(value), - "physical_key": str(self.pyhsical_key), - }, - ) from exc - self.async_write_ha_state() - - -class LaMarzoccoScaleTargetNumberEntity( - LaMarzoccoKeyNumberEntity, LaMarzoccScaleEntity -): - """Entity representing a key number on the scale.""" - - entity_description: LaMarzoccoKeyNumberEntityDescription diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index 5ebe2d7b9da..44dad6bfb2a 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -2,18 +2,18 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Any +from typing import Any, cast from pylamarzocco.const import ( - MachineModel, - PhysicalKey, - PrebrewMode, - SmartStandbyMode, - SteamLevel, + ModelName, + PreExtractionMode, + SmartStandByType, + SteamTargetLevel, + WidgetType, ) -from pylamarzocco.devices.machine import LaMarzoccoMachine +from pylamarzocco.devices import LaMarzoccoMachine from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.models import LaMarzoccoMachineConfig +from pylamarzocco.models import PreBrewing, SteamBoilerLevel from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory @@ -23,30 +23,29 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import LaMarzoccoConfigEntry -from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription PARALLEL_UPDATES = 1 STEAM_LEVEL_HA_TO_LM = { - "1": SteamLevel.LEVEL_1, - "2": SteamLevel.LEVEL_2, - "3": SteamLevel.LEVEL_3, + "1": SteamTargetLevel.LEVEL_1, + "2": SteamTargetLevel.LEVEL_2, + "3": SteamTargetLevel.LEVEL_3, } STEAM_LEVEL_LM_TO_HA = {value: key for key, value in STEAM_LEVEL_HA_TO_LM.items()} PREBREW_MODE_HA_TO_LM = { - "disabled": PrebrewMode.DISABLED, - "prebrew": PrebrewMode.PREBREW, - "prebrew_enabled": PrebrewMode.PREBREW_ENABLED, - "preinfusion": PrebrewMode.PREINFUSION, + "disabled": PreExtractionMode.DISABLED, + "prebrew": PreExtractionMode.PREBREWING, + "preinfusion": PreExtractionMode.PREINFUSION, } PREBREW_MODE_LM_TO_HA = {value: key for key, value in PREBREW_MODE_HA_TO_LM.items()} STANDBY_MODE_HA_TO_LM = { - "power_on": SmartStandbyMode.POWER_ON, - "last_brewing": SmartStandbyMode.LAST_BREWING, + "power_on": SmartStandByType.POWER_ON, + "last_brewing": SmartStandByType.LAST_BREW, } STANDBY_MODE_LM_TO_HA = {value: key for key, value in STANDBY_MODE_HA_TO_LM.items()} @@ -59,7 +58,7 @@ class LaMarzoccoSelectEntityDescription( ): """Description of a La Marzocco select entity.""" - current_option_fn: Callable[[LaMarzoccoMachineConfig], str | None] + current_option_fn: Callable[[LaMarzoccoMachine], str | None] select_option_fn: Callable[[LaMarzoccoMachine, str], Coroutine[Any, Any, bool]] @@ -71,25 +70,36 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( select_option_fn=lambda machine, option: machine.set_steam_level( STEAM_LEVEL_HA_TO_LM[option] ), - current_option_fn=lambda config: STEAM_LEVEL_LM_TO_HA[config.steam_level], - supported_fn=lambda coordinator: coordinator.device.model - == MachineModel.LINEA_MICRA, + current_option_fn=lambda machine: STEAM_LEVEL_LM_TO_HA[ + cast( + SteamBoilerLevel, + machine.dashboard.config[WidgetType.CM_STEAM_BOILER_LEVEL], + ).target_level + ], + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA) + ), ), LaMarzoccoSelectEntityDescription( key="prebrew_infusion_select", translation_key="prebrew_infusion_select", entity_category=EntityCategory.CONFIG, options=["disabled", "prebrew", "preinfusion"], - select_option_fn=lambda machine, option: machine.set_prebrew_mode( + select_option_fn=lambda machine, option: machine.set_pre_extraction_mode( PREBREW_MODE_HA_TO_LM[option] ), - current_option_fn=lambda config: PREBREW_MODE_LM_TO_HA[config.prebrew_mode], - supported_fn=lambda coordinator: coordinator.device.model - in ( - MachineModel.GS3_AV, - MachineModel.LINEA_MICRA, - MachineModel.LINEA_MINI, - MachineModel.LINEA_MINI_R, + current_option_fn=lambda machine: PREBREW_MODE_LM_TO_HA[ + cast(PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]).mode + ], + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in ( + ModelName.LINEA_MICRA, + ModelName.LINEA_MINI, + ModelName.LINEA_MINI_R, + ModelName.GS3_AV, + ) ), ), LaMarzoccoSelectEntityDescription( @@ -98,32 +108,16 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, options=["power_on", "last_brewing"], select_option_fn=lambda machine, option: machine.set_smart_standby( - enabled=machine.config.smart_standby.enabled, + enabled=machine.schedule.smart_wake_up_sleep.smart_stand_by_enabled, mode=STANDBY_MODE_HA_TO_LM[option], - minutes=machine.config.smart_standby.minutes, + minutes=machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes, ), - current_option_fn=lambda config: STANDBY_MODE_LM_TO_HA[ - config.smart_standby.mode + current_option_fn=lambda machine: STANDBY_MODE_LM_TO_HA[ + machine.schedule.smart_wake_up_sleep.smart_stand_by_after ], ), ) -SCALE_ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( - LaMarzoccoSelectEntityDescription( - key="active_bbw", - translation_key="active_bbw", - options=["a", "b"], - select_option_fn=lambda machine, option: machine.set_active_bbw_recipe( - PhysicalKey[option.upper()] - ), - current_option_fn=lambda config: ( - config.bbw_settings.active_dose.name.lower() - if config.bbw_settings - else None - ), - ), -) - async def async_setup_entry( hass: HomeAssistant, @@ -133,30 +127,11 @@ async def async_setup_entry( """Set up select entities.""" coordinator = entry.runtime_data.config_coordinator - entities = [ + async_add_entities( LaMarzoccoSelectEntity(coordinator, description) for description in ENTITIES if description.supported_fn(coordinator) - ] - - if ( - coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) - and coordinator.device.config.scale - ): - entities.extend( - LaMarzoccoScaleSelectEntity(coordinator, description) - for description in SCALE_ENTITIES - ) - - def _async_add_new_scale() -> None: - async_add_entities( - LaMarzoccoScaleSelectEntity(coordinator, description) - for description in SCALE_ENTITIES - ) - - coordinator.new_device_callback.append(_async_add_new_scale) - - async_add_entities(entities) + ) class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity): @@ -167,9 +142,7 @@ class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity): @property def current_option(self) -> str | None: """Return the current selected option.""" - return str( - self.entity_description.current_option_fn(self.coordinator.device.config) - ) + return self.entity_description.current_option_fn(self.coordinator.device) async def async_select_option(self, option: str) -> None: """Change the selected option.""" @@ -188,9 +161,3 @@ class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity): }, ) from exc self.async_write_ha_state() - - -class LaMarzoccoScaleSelectEntity(LaMarzoccoSelectEntity, LaMarzoccScaleEntity): - """Select entity for La Marzocco scales.""" - - entity_description: LaMarzoccoSelectEntityDescription diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 0d4a5e53ebe..5dc0eb3dbef 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -2,9 +2,18 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime +from typing import cast -from pylamarzocco.const import KEYS_PER_MODEL, BoilerType, MachineModel, PhysicalKey -from pylamarzocco.devices.machine import LaMarzoccoMachine +from pylamarzocco.const import ModelName, WidgetType +from pylamarzocco.models import ( + BackFlush, + BaseWidgetOutput, + CoffeeAndFlushCounter, + CoffeeBoiler, + SteamBoilerLevel, + SteamBoilerTemperature, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -12,17 +21,13 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import ( - PERCENTAGE, - EntityCategory, - UnitOfTemperature, - UnitOfTime, -) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType -from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator -from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity +from .coordinator import LaMarzoccoConfigEntry +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -30,104 +35,93 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) class LaMarzoccoSensorEntityDescription( - LaMarzoccoEntityDescription, SensorEntityDescription + LaMarzoccoEntityDescription, + SensorEntityDescription, ): """Description of a La Marzocco sensor.""" - value_fn: Callable[[LaMarzoccoMachine], float | int] - - -@dataclass(frozen=True, kw_only=True) -class LaMarzoccoKeySensorEntityDescription( - LaMarzoccoEntityDescription, SensorEntityDescription -): - """Description of a keyed La Marzocco sensor.""" - - value_fn: Callable[[LaMarzoccoMachine, PhysicalKey], int | None] + value_fn: Callable[ + [dict[WidgetType, BaseWidgetOutput]], StateType | datetime | None + ] ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( - key="shot_timer", - translation_key="shot_timer", - native_unit_of_measurement=UnitOfTime.SECONDS, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.DURATION, - value_fn=lambda device: device.config.brew_active_duration, - available_fn=lambda device: device.websocket_connected, + key="coffee_boiler_ready_time", + translation_key="coffee_boiler_ready_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=( + lambda config: cast( + CoffeeBoiler, config[WidgetType.CM_COFFEE_BOILER] + ).ready_start_time + ), entity_category=EntityCategory.DIAGNOSTIC, - supported_fn=lambda coordinator: coordinator.local_connection_configured, ), LaMarzoccoSensorEntityDescription( - key="current_temp_coffee", - translation_key="current_temp_coffee", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=1, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.TEMPERATURE, - value_fn=lambda device: device.config.boilers[ - BoilerType.COFFEE - ].current_temperature, + key="steam_boiler_ready_time", + translation_key="steam_boiler_ready_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=( + lambda config: cast( + SteamBoilerLevel, config[WidgetType.CM_STEAM_BOILER_LEVEL] + ).ready_start_time + ), + entity_category=EntityCategory.DIAGNOSTIC, + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in (ModelName.LINEA_MICRA, ModelName.LINEA_MINI_R) + ), ), LaMarzoccoSensorEntityDescription( - key="current_temp_steam", - translation_key="current_temp_steam", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=1, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.TEMPERATURE, - value_fn=lambda device: device.config.boilers[ - BoilerType.STEAM - ].current_temperature, - supported_fn=lambda coordinator: coordinator.device.model - not in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R), + key="steam_boiler_ready_time", + translation_key="steam_boiler_ready_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=( + lambda config: cast( + SteamBoilerTemperature, config[WidgetType.CM_STEAM_BOILER_TEMPERATURE] + ).ready_start_time + ), + entity_category=EntityCategory.DIAGNOSTIC, + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in (ModelName.GS3_AV, ModelName.GS3_MP, ModelName.LINEA_MINI) + ), + ), + LaMarzoccoSensorEntityDescription( + key="last_cleaning_time", + translation_key="last_cleaning_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=( + lambda config: cast( + BackFlush, config[WidgetType.CM_BACK_FLUSH] + ).last_cleaning_start_time + ), + entity_category=EntityCategory.DIAGNOSTIC, ), ) STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="drink_stats_coffee", - translation_key="drink_stats_coffee", + translation_key="total_coffees_made", state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda device: device.statistics.total_coffee, - available_fn=lambda device: len(device.statistics.drink_stats) > 0, + value_fn=( + lambda statistics: cast( + CoffeeAndFlushCounter, statistics[WidgetType.COFFEE_AND_FLUSH_COUNTER] + ).total_coffee + ), entity_category=EntityCategory.DIAGNOSTIC, ), LaMarzoccoSensorEntityDescription( key="drink_stats_flushing", - translation_key="drink_stats_flushing", + translation_key="total_flushes_done", state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda device: device.statistics.total_flushes, - available_fn=lambda device: len(device.statistics.drink_stats) > 0, - entity_category=EntityCategory.DIAGNOSTIC, - ), -) - -KEY_STATISTIC_ENTITIES: tuple[LaMarzoccoKeySensorEntityDescription, ...] = ( - LaMarzoccoKeySensorEntityDescription( - key="drink_stats_coffee_key", - translation_key="drink_stats_coffee_key", - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda device, key: device.statistics.drink_stats.get(key), - available_fn=lambda device: len(device.statistics.drink_stats) > 0, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), -) - -SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( - LaMarzoccoSensorEntityDescription( - key="scale_battery", - native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.BATTERY, - value_fn=lambda device: ( - device.config.scale.battery if device.config.scale else 0 - ), - supported_fn=( - lambda coordinator: coordinator.device.model - in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) + value_fn=( + lambda statistics: cast( + CoffeeAndFlushCounter, statistics[WidgetType.COFFEE_AND_FLUSH_COUNTER] + ).total_flush ), + entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -138,89 +132,40 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor entities.""" - config_coordinator = entry.runtime_data.config_coordinator - - entities: list[LaMarzoccoSensorEntity | LaMarzoccoKeySensorEntity] = [] + coordinator = entry.runtime_data.config_coordinator entities = [ - LaMarzoccoSensorEntity(config_coordinator, description) + LaMarzoccoSensorEntity(coordinator, description) for description in ENTITIES - if description.supported_fn(config_coordinator) + if description.supported_fn(coordinator) ] - - if ( - config_coordinator.device.model - in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) - and config_coordinator.device.config.scale - ): - entities.extend( - LaMarzoccoScaleSensorEntity(config_coordinator, description) - for description in SCALE_ENTITIES - ) - - statistics_coordinator = entry.runtime_data.statistics_coordinator entities.extend( - LaMarzoccoSensorEntity(statistics_coordinator, description) + LaMarzoccoStatisticSensorEntity(coordinator, description) for description in STATISTIC_ENTITIES - if description.supported_fn(statistics_coordinator) + if description.supported_fn(coordinator) ) - - num_keys = KEYS_PER_MODEL[MachineModel(config_coordinator.device.model)] - if num_keys > 0: - entities.extend( - LaMarzoccoKeySensorEntity(statistics_coordinator, description, key) - for description in KEY_STATISTIC_ENTITIES - for key in range(1, num_keys + 1) - ) - - def _async_add_new_scale() -> None: - async_add_entities( - LaMarzoccoScaleSensorEntity(config_coordinator, description) - for description in SCALE_ENTITIES - ) - - config_coordinator.new_device_callback.append(_async_add_new_scale) - async_add_entities(entities) class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): - """Sensor representing espresso machine temperature data.""" + """Sensor for La Marzocco.""" entity_description: LaMarzoccoSensorEntityDescription @property - def native_value(self) -> int | float | None: - """State of the sensor.""" - return self.entity_description.value_fn(self.coordinator.device) - - -class LaMarzoccoKeySensorEntity(LaMarzoccoEntity, SensorEntity): - """Sensor for a La Marzocco key.""" - - entity_description: LaMarzoccoKeySensorEntityDescription - - def __init__( - self, - coordinator: LaMarzoccoUpdateCoordinator, - description: LaMarzoccoKeySensorEntityDescription, - key: int, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, description) - self.key = key - self._attr_translation_placeholders = {"key": str(key)} - self._attr_unique_id = f"{super()._attr_unique_id}_key{key}" - - @property - def native_value(self) -> int | None: - """State of the sensor.""" + def native_value(self) -> StateType | datetime | None: + """Return value of the sensor.""" return self.entity_description.value_fn( - self.coordinator.device, PhysicalKey(self.key) + self.coordinator.device.dashboard.config ) -class LaMarzoccoScaleSensorEntity(LaMarzoccoSensorEntity, LaMarzoccScaleEntity): - """Sensor for a La Marzocco scale.""" +class LaMarzoccoStatisticSensorEntity(LaMarzoccoSensorEntity): + """Sensor for La Marzocco statistics.""" - entity_description: LaMarzoccoSensorEntityDescription + @property + def native_value(self) -> StateType | datetime | None: + """Return the value of the sensor.""" + return self.entity_description.value_fn( + self.coordinator.device.statistics.widgets + ) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 04853b8d0ca..6383e931c22 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -32,13 +32,11 @@ } }, "machine_selection": { - "description": "Select the machine you want to integrate. Set the \"IP\" to get access to shot time related sensors.", + "description": "Select the machine you want to integrate.", "data": { - "host": "[%key:common::config_flow::data::ip%]", "machine": "Machine" }, "data_description": { - "host": "Local IP address of the machine", "machine": "Select the machine you want to integrate" } }, @@ -85,6 +83,9 @@ }, "water_tank": { "name": "Water tank empty" + }, + "websocket_connected": { + "name": "WebSocket connected" } }, "button": { @@ -101,54 +102,25 @@ "coffee_temp": { "name": "Coffee target temperature" }, - "dose_key": { - "name": "Dose Key {key}" - }, - "prebrew_on": { - "name": "Prebrew on time" - }, - "prebrew_on_key": { - "name": "Prebrew on time Key {key}" - }, - "prebrew_off": { - "name": "Prebrew off time" - }, - "prebrew_off_key": { - "name": "Prebrew off time Key {key}" - }, - "preinfusion_off": { - "name": "Preinfusion time" - }, - "preinfusion_off_key": { - "name": "Preinfusion time Key {key}" - }, - "scale_target_key": { - "name": "Brew by weight target {key}" - }, "smart_standby_time": { "name": "Smart standby time" }, - "steam_temp": { - "name": "Steam target temperature" + "preinfusion_time": { + "name": "Preinfusion time" }, - "tea_water_duration": { - "name": "Tea water duration" + "prebrew_time_on": { + "name": "Prebrew on time" + }, + "prebrew_time_off": { + "name": "Prebrew off time" } }, "select": { - "active_bbw": { - "name": "Active brew by weight recipe", - "state": { - "a": "Recipe A", - "b": "Recipe B" - } - }, "prebrew_infusion_select": { "name": "Prebrew/-infusion mode", "state": { - "disabled": "Disabled", + "disabled": "[%key:common::state::disabled%]", "prebrew": "Prebrew", - "prebrew_enabled": "Prebrew", "preinfusion": "Preinfusion" } }, @@ -169,26 +141,22 @@ } }, "sensor": { - "current_temp_coffee": { - "name": "Current coffee temperature" + "coffee_boiler_ready_time": { + "name": "Coffee boiler ready time" }, - "current_temp_steam": { - "name": "Current steam temperature" + "steam_boiler_ready_time": { + "name": "Steam boiler ready time" }, - "drink_stats_coffee": { + "total_coffees_made": { "name": "Total coffees made", "unit_of_measurement": "coffees" }, - "drink_stats_coffee_key": { - "name": "Coffees made Key {key}", - "unit_of_measurement": "coffees" - }, - "drink_stats_flushing": { - "name": "Total flushes made", + "total_flushes_done": { + "name": "Total flushes done", "unit_of_measurement": "flushes" }, - "shot_timer": { - "name": "Shot timer" + "last_cleaning_time": { + "name": "Last cleaning time" } }, "switch": { @@ -233,9 +201,6 @@ "number_exception": { "message": "Error while setting value {value} for number {key}" }, - "number_exception_key": { - "message": "Error while setting value {value} for number {key}, key {physical_key}" - }, "select_option_error": { "message": "Error while setting select option {option} for {key}" }, diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py index ee03ba421d4..ca5fb820150 100644 --- a/homeassistant/components/lamarzocco/switch.py +++ b/homeassistant/components/lamarzocco/switch.py @@ -2,12 +2,17 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Any +from typing import Any, cast -from pylamarzocco.const import BoilerType -from pylamarzocco.devices.machine import LaMarzoccoMachine +from pylamarzocco import LaMarzoccoMachine +from pylamarzocco.const import MachineMode, ModelName, WidgetType from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.models import LaMarzoccoMachineConfig +from pylamarzocco.models import ( + MachineStatus, + SteamBoilerLevel, + SteamBoilerTemperature, + WakeUpScheduleSettings, +) from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -30,7 +35,7 @@ class LaMarzoccoSwitchEntityDescription( """Description of a La Marzocco Switch.""" control_fn: Callable[[LaMarzoccoMachine, bool], Coroutine[Any, Any, bool]] - is_on_fn: Callable[[LaMarzoccoMachineConfig], bool] + is_on_fn: Callable[[LaMarzoccoMachine], bool] ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( @@ -39,13 +44,42 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( translation_key="main", name=None, control_fn=lambda machine, state: machine.set_power(state), - is_on_fn=lambda config: config.turned_on, + is_on_fn=( + lambda machine: cast( + MachineStatus, machine.dashboard.config[WidgetType.CM_MACHINE_STATUS] + ).mode + is MachineMode.BREWING_MODE + ), ), LaMarzoccoSwitchEntityDescription( key="steam_boiler_enable", translation_key="steam_boiler", control_fn=lambda machine, state: machine.set_steam(state), - is_on_fn=lambda config: config.boilers[BoilerType.STEAM].enabled, + is_on_fn=( + lambda machine: cast( + SteamBoilerLevel, + machine.dashboard.config[WidgetType.CM_STEAM_BOILER_LEVEL], + ).enabled + ), + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA) + ), + ), + LaMarzoccoSwitchEntityDescription( + key="steam_boiler_enable", + translation_key="steam_boiler", + control_fn=lambda machine, state: machine.set_steam(state), + is_on_fn=( + lambda machine: cast( + SteamBoilerTemperature, + machine.dashboard.config[WidgetType.CM_STEAM_BOILER_TEMPERATURE], + ).enabled + ), + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + not in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA) + ), ), LaMarzoccoSwitchEntityDescription( key="smart_standby_enabled", @@ -53,10 +87,10 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, control_fn=lambda machine, state: machine.set_smart_standby( enabled=state, - mode=machine.config.smart_standby.mode, - minutes=machine.config.smart_standby.minutes, + mode=machine.schedule.smart_wake_up_sleep.smart_stand_by_after, + minutes=machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes, ), - is_on_fn=lambda config: config.smart_standby.enabled, + is_on_fn=lambda machine: machine.schedule.smart_wake_up_sleep.smart_stand_by_enabled, ), ) @@ -78,8 +112,8 @@ async def async_setup_entry( ) entities.extend( - LaMarzoccoAutoOnOffSwitchEntity(coordinator, wake_up_sleep_entry_id) - for wake_up_sleep_entry_id in coordinator.device.config.wake_up_sleep_entries + LaMarzoccoAutoOnOffSwitchEntity(coordinator, wake_up_sleep_entry) + for wake_up_sleep_entry in coordinator.device.schedule.smart_wake_up_sleep.schedules ) async_add_entities(entities) @@ -117,7 +151,7 @@ class LaMarzoccoSwitchEntity(LaMarzoccoEntity, SwitchEntity): @property def is_on(self) -> bool: """Return true if device is on.""" - return self.entity_description.is_on_fn(self.coordinator.device.config) + return self.entity_description.is_on_fn(self.coordinator.device) class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity): @@ -129,22 +163,21 @@ class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity): def __init__( self, coordinator: LaMarzoccoUpdateCoordinator, - identifier: str, + schedule_entry: WakeUpScheduleSettings, ) -> None: """Initialize the switch.""" - super().__init__(coordinator, f"auto_on_off_{identifier}") - self._identifier = identifier - self._attr_translation_placeholders = {"id": identifier} - self.entity_category = EntityCategory.CONFIG + super().__init__(coordinator, f"auto_on_off_{schedule_entry.identifier}") + assert schedule_entry.identifier + self._schedule_entry = schedule_entry + self._identifier = schedule_entry.identifier + self._attr_translation_placeholders = {"id": schedule_entry.identifier} + self._attr_entity_category = EntityCategory.CONFIG async def _async_enable(self, state: bool) -> None: """Enable or disable the auto on/off schedule.""" - wake_up_sleep_entry = self.coordinator.device.config.wake_up_sleep_entries[ - self._identifier - ] - wake_up_sleep_entry.enabled = state + self._schedule_entry.enabled = state try: - await self.coordinator.device.set_wake_up_sleep(wake_up_sleep_entry) + await self.coordinator.device.set_wakeup_schedule(self._schedule_entry) except RequestNotSuccessful as exc: raise HomeAssistantError( translation_domain=DOMAIN, @@ -164,6 +197,4 @@ class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity): @property def is_on(self) -> bool: """Return true if switch is on.""" - return self.coordinator.device.config.wake_up_sleep_entries[ - self._identifier - ].enabled + return self._schedule_entry.enabled diff --git a/homeassistant/components/lamarzocco/update.py b/homeassistant/components/lamarzocco/update.py index 37960d26e95..632c66a8b66 100644 --- a/homeassistant/components/lamarzocco/update.py +++ b/homeassistant/components/lamarzocco/update.py @@ -1,9 +1,10 @@ """Support for La Marzocco update entities.""" +import asyncio from dataclasses import dataclass from typing import Any -from pylamarzocco.const import FirmwareType +from pylamarzocco.const import FirmwareType, UpdateCommandStatus from pylamarzocco.exceptions import RequestNotSuccessful from homeassistant.components.update import ( @@ -22,6 +23,7 @@ from .coordinator import LaMarzoccoConfigEntry from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription PARALLEL_UPDATES = 1 +MAX_UPDATE_WAIT = 150 @dataclass(frozen=True, kw_only=True) @@ -59,7 +61,7 @@ async def async_setup_entry( ) -> None: """Create update entities.""" - coordinator = entry.runtime_data.firmware_coordinator + coordinator = entry.runtime_data.settings_coordinator async_add_entities( LaMarzoccoUpdateEntity(coordinator, description) for description in ENTITIES @@ -71,38 +73,67 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity): """Entity representing the update state.""" entity_description: LaMarzoccoUpdateEntityDescription - _attr_supported_features = UpdateEntityFeature.INSTALL + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.RELEASE_NOTES + ) @property - def installed_version(self) -> str | None: + def installed_version(self) -> str: """Return the current firmware version.""" - return self.coordinator.device.firmware[ + return self.coordinator.device.settings.firmwares[ self.entity_description.component - ].current_version + ].build_version @property def latest_version(self) -> str: """Return the latest firmware version.""" - return self.coordinator.device.firmware[ + if available_update := self.coordinator.device.settings.firmwares[ self.entity_description.component - ].latest_version + ].available_update: + return available_update.build_version + return self.installed_version @property def release_url(self) -> str | None: """Return the release notes URL.""" return "https://support-iot.lamarzocco.com/firmware-updates/" + def release_notes(self) -> str | None: + """Return the release notes for the latest firmware version.""" + if available_update := self.coordinator.device.settings.firmwares[ + self.entity_description.component + ].available_update: + return available_update.change_log + return None + async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" + self._attr_in_progress = True self.async_write_ha_state() + + counter = 0 + + def _raise_timeout_error() -> None: # to avoid TRY301 + raise TimeoutError("Update timed out") + try: - success = await self.coordinator.device.update_firmware( - self.entity_description.component - ) - except RequestNotSuccessful as exc: + await self.coordinator.device.update_firmware() + while ( + update_progress := await self.coordinator.device.get_firmware() + ).command_status is UpdateCommandStatus.IN_PROGRESS: + if counter >= MAX_UPDATE_WAIT: + _raise_timeout_error() + self._attr_update_percentage = update_progress.progress_percentage + self.async_write_ha_state() + await asyncio.sleep(3) + counter += 1 + + except (TimeoutError, RequestNotSuccessful) as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="update_failed", @@ -110,13 +141,6 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity): "key": self.entity_description.key, }, ) from exc - if not success: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="update_failed", - translation_placeholders={ - "key": self.entity_description.key, - }, - ) - self._attr_in_progress = False - await self.coordinator.async_request_refresh() + finally: + self._attr_in_progress = False + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 3c2f05fa535..0656454bb01 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -18,7 +18,7 @@ }, "data_description": { "host": "The IP address or hostname of your LaMetric TIME on your network.", - "api_key": "You can find this API key in [devices page in your LaMetric developer account](https://developer.lametric.com/user/devices)." + "api_key": "You can find this API key in the [devices page in your LaMetric developer account](https://developer.lametric.com/user/devices)." } }, "cloud_select_device": { @@ -83,8 +83,8 @@ "brightness_mode": { "name": "Brightness mode", "state": { - "auto": "Automatic", - "manual": "Manual" + "auto": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]" } } }, diff --git a/homeassistant/components/lawn_mower/strings.json b/homeassistant/components/lawn_mower/strings.json index ebaea4ffd6a..9cc56b8a11e 100644 --- a/homeassistant/components/lawn_mower/strings.json +++ b/homeassistant/components/lawn_mower/strings.json @@ -4,7 +4,7 @@ "_": { "name": "[%key:component::lawn_mower::title%]", "state": { - "error": "Error", + "error": "[%key:common::state::error%]", "paused": "[%key:common::state::paused%]", "mowing": "Mowing", "docked": "Docked", diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 256e132b30d..b3d2c14794c 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -24,12 +24,17 @@ from homeassistant.const import ( CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, + CONF_RESOURCE, CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.typing import ConfigType from .const import ( @@ -38,6 +43,7 @@ from .const import ( CONF_DIM_MODE, CONF_DOMAIN_DATA, CONF_SK_NUM_TRIES, + CONF_TARGET_VALUE_LOCKED, CONF_TRANSITION, CONNECTION, DEVICE_CONNECTIONS, @@ -155,6 +161,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if config_entry.minor_version < 2: new_data[CONF_ACKNOWLEDGE] = False + if config_entry.version < 2: # update to 2.1 (fix transitions for lights and switches) new_entities_data = [*new_data[CONF_ENTITIES]] for entity in new_entities_data: @@ -164,8 +171,19 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> entity[CONF_DOMAIN_DATA][CONF_TRANSITION] /= 1000.0 new_data[CONF_ENTITIES] = new_entities_data + if config_entry.version < 3: + # update to 3.1 (remove resource parameter, add climate target lock value parameter) + for entity in new_data[CONF_ENTITIES]: + entity.pop(CONF_RESOURCE, None) + + if entity[CONF_DOMAIN] == Platform.CLIMATE: + entity[CONF_DOMAIN_DATA].setdefault(CONF_TARGET_VALUE_LOCKED, -1) + + # migrate climate and scene unique ids + await async_migrate_entities(hass, config_entry) + hass.config_entries.async_update_entry( - config_entry, data=new_data, minor_version=1, version=2 + config_entry, data=new_data, minor_version=1, version=3 ) _LOGGER.debug( @@ -176,6 +194,29 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True +async def async_migrate_entities( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate entity registry.""" + + @callback + def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + # fix unique entity ids for climate and scene + if "." in entity_entry.unique_id: + if entity_entry.domain == Platform.CLIMATE: + setpoint = entity_entry.unique_id.split(".")[-1] + return { + "new_unique_id": entity_entry.unique_id.rsplit("-", 1)[0] + + f"-{setpoint}" + } + if entity_entry.domain == Platform.SCENE: + return {"new_unique_id": entity_entry.unique_id.replace(".", "")} + return None + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + + async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Close connection to PCHK host represented by config_entry.""" # forward unloading to platforms diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index 63e0d8c8b26..946c7ac3724 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -110,7 +110,7 @@ async def validate_connection(data: ConfigType) -> str | None: class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a LCN config flow.""" - VERSION = 2 + VERSION = 3 MINOR_VERSION = 1 async def async_step_user( diff --git a/homeassistant/components/lcn/entity.py b/homeassistant/components/lcn/entity.py index ffb680c4237..24897287449 100644 --- a/homeassistant/components/lcn/entity.py +++ b/homeassistant/components/lcn/entity.py @@ -3,18 +3,19 @@ from collections.abc import Callable from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_RESOURCE +from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import CONF_DOMAIN_DATA, DOMAIN from .helpers import ( AddressType, DeviceConnectionType, InputType, generate_unique_id, get_device_connection, + get_resource, ) @@ -48,7 +49,11 @@ class LcnEntity(Entity): def unique_id(self) -> str: """Return a unique ID.""" return generate_unique_id( - self.config_entry.entry_id, self.address, self.config[CONF_RESOURCE] + self.config_entry.entry_id, + self.address, + get_resource( + self.config[CONF_DOMAIN], self.config[CONF_DOMAIN_DATA] + ).lower(), ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 2176c669251..a2796f88368 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -19,7 +19,6 @@ from homeassistant.const import ( CONF_ENTITIES, CONF_LIGHTS, CONF_NAME, - CONF_RESOURCE, CONF_SENSORS, CONF_SWITCHES, ) @@ -29,6 +28,7 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_CLIMATES, + CONF_DOMAIN_DATA, CONF_HARDWARE_SERIAL, CONF_HARDWARE_TYPE, CONF_SCENES, @@ -79,9 +79,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 cast(str, 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") @@ -115,7 +115,9 @@ def purge_entity_registry( references_entry_data = set() for entity_data in imported_entry_data[CONF_ENTITIES]: entity_unique_id = generate_unique_id( - entry_id, entity_data[CONF_ADDRESS], entity_data[CONF_RESOURCE] + entry_id, + entity_data[CONF_ADDRESS], + get_resource(entity_data[CONF_DOMAIN], entity_data[CONF_DOMAIN_DATA]), ) entity_id = entity_registry.async_get_entity_id( entity_data[CONF_DOMAIN], DOMAIN, entity_unique_id diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index c1dd7751940..e5313eee4f3 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.5", "lcn-frontend==0.2.3"] + "requirements": ["pypck==0.8.5", "lcn-frontend==0.2.4"] } diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py index 9084ec838d9..545ee1e0043 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -19,7 +19,6 @@ from homeassistant.const import ( CONF_DOMAIN, CONF_ENTITIES, CONF_NAME, - CONF_RESOURCE, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import ( @@ -343,7 +342,6 @@ async def websocket_add_entity( entity_config = { CONF_ADDRESS: msg[CONF_ADDRESS], CONF_NAME: msg[CONF_NAME], - CONF_RESOURCE: resource, CONF_DOMAIN: domain_name, CONF_DOMAIN_DATA: domain_data, } @@ -371,7 +369,15 @@ async def websocket_add_entity( vol.Required("entry_id"): cv.string, vol.Required(CONF_ADDRESS): ADDRESS_SCHEMA, vol.Required(CONF_DOMAIN): cv.string, - vol.Required(CONF_RESOURCE): cv.string, + vol.Required(CONF_DOMAIN_DATA): vol.Any( + DOMAIN_DATA_BINARY_SENSOR, + DOMAIN_DATA_SENSOR, + DOMAIN_DATA_SWITCH, + DOMAIN_DATA_LIGHT, + DOMAIN_DATA_CLIMATE, + DOMAIN_DATA_COVER, + DOMAIN_DATA_SCENE, + ), } ) @websocket_api.async_response @@ -390,7 +396,10 @@ async def websocket_delete_entity( if ( tuple(entity_config[CONF_ADDRESS]) == msg[CONF_ADDRESS] and entity_config[CONF_DOMAIN] == msg[CONF_DOMAIN] - and entity_config[CONF_RESOURCE] == msg[CONF_RESOURCE] + and get_resource( + entity_config[CONF_DOMAIN], entity_config[CONF_DOMAIN_DATA] + ) + == get_resource(msg[CONF_DOMAIN], msg[CONF_DOMAIN_DATA]) ) ), None, diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 764345710dd..ba5ca3bdba4 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.26.5", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.28.1", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/leaone/manifest.json b/homeassistant/components/leaone/manifest.json index 97ac8a06e97..b7b9b5b1c38 100644 --- a/homeassistant/components/leaone/manifest.json +++ b/homeassistant/components/leaone/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/leaone", "iot_class": "local_push", - "requirements": ["leaone-ble==0.1.0"] + "requirements": ["leaone-ble==0.3.0"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 14983e5fdfe..49daafeca25 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.26.5", "led-ble==1.1.7"] + "requirements": ["bluetooth-data-tools==1.28.1", "led-ble==1.1.7"] } diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index eb0203e0661..23aac0b3059 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -87,11 +87,11 @@ "state": { "available": "Available", "charging": "[%key:common::state::charging%]", - "connected": "Connected", - "error": "Error", - "locked": "Locked", + "connected": "[%key:common::state::connected%]", + "error": "[%key:common::state::error%]", + "locked": "[%key:common::state::locked%]", "need_auth": "Waiting for authentication", - "paused": "Paused", + "paused": "[%key:common::state::paused%]", "paused_by_scheduler": "Paused by scheduler", "updating_firmware": "Updating firmware" } @@ -118,7 +118,7 @@ "ocpp": "OCPP", "overtemperature": "Overtemperature", "switching_phases": "Switching phases", - "1p_charging_disabled": "1p charging disabled" + "1p_charging_disabled": "1P charging disabled" } }, "breaker_current": { diff --git a/homeassistant/components/lg_thinq/__init__.py b/homeassistant/components/lg_thinq/__init__.py index f83cbadf925..47282b6cc22 100644 --- a/homeassistant/components/lg_thinq/__init__.py +++ b/homeassistant/components/lg_thinq/__init__.py @@ -22,7 +22,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_time_interval -from .const import CONF_CONNECT_CLIENT_ID, MQTT_SUBSCRIPTION_INTERVAL +from .const import CONF_CONNECT_CLIENT_ID, DOMAIN, MQTT_SUBSCRIPTION_INTERVAL from .coordinator import DeviceDataUpdateCoordinator, async_setup_device_coordinator from .mqtt import ThinQMQTT @@ -137,7 +137,15 @@ async def async_setup_mqtt( entry.runtime_data.mqtt_client = mqtt_client # Try to connect. - result = await mqtt_client.async_connect() + try: + result = await mqtt_client.async_connect() + except (AttributeError, ThinQAPIException, TypeError, ValueError) as exc: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_connect_mqtt", + translation_placeholders={"error": str(exc)}, + ) from exc + if not result: _LOGGER.error("Failed to set up mqtt connection") return diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 787b50167c1..3b0baaaaf75 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -169,6 +169,9 @@ "current_job_mode": { "default": "mdi:format-list-bulleted" }, + "current_job_mode_dehumidifier": { + "default": "mdi:format-list-bulleted" + }, "operation_mode": { "default": "mdi:gesture-tap-button" }, diff --git a/homeassistant/components/lg_thinq/mqtt.py b/homeassistant/components/lg_thinq/mqtt.py index 025f80f78b1..d6ff1f72b8f 100644 --- a/homeassistant/components/lg_thinq/mqtt.py +++ b/homeassistant/components/lg_thinq/mqtt.py @@ -43,19 +43,16 @@ class ThinQMQTT: async def async_connect(self) -> bool: """Create a mqtt client and then try to connect.""" - try: - self.client = await ThinQMQTTClient( - self.thinq_api, self.client_id, self.on_message_received - ) - if self.client is None: - return False - # Connect to server and create certificate. - return await self.client.async_prepare_mqtt() - except (ThinQAPIException, TypeError, ValueError): - _LOGGER.exception("Failed to connect") + self.client = await ThinQMQTTClient( + self.thinq_api, self.client_id, self.on_message_received + ) + if self.client is None: return False + # Connect to server and create certificate. + return await self.client.async_prepare_mqtt() + async def async_disconnect(self, event: Event | None = None) -> None: """Unregister client and disconnects handlers.""" await self.async_end_subscribes() diff --git a/homeassistant/components/lg_thinq/number.py b/homeassistant/components/lg_thinq/number.py index 7003519e0ce..ac8991d6bb5 100644 --- a/homeassistant/components/lg_thinq/number.py +++ b/homeassistant/components/lg_thinq/number.py @@ -123,6 +123,9 @@ DEVICE_TYPE_NUMBER_MAP: dict[DeviceType, tuple[NumberEntityDescription, ...]] = NUMBER_DESC[ThinQProperty.LIGHT_STATUS], NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE], ), + DeviceType.VENTILATOR: ( + TIMER_NUMBER_DESC[ThinQProperty.SLEEP_TIMER_RELATIVE_HOUR_TO_STOP], + ), } _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lg_thinq/select.py b/homeassistant/components/lg_thinq/select.py index 929fa0b1d28..3f29ee9e5c8 100644 --- a/homeassistant/components/lg_thinq/select.py +++ b/homeassistant/components/lg_thinq/select.py @@ -98,7 +98,13 @@ DEVICE_TYPE_SELECT_MAP: dict[DeviceType, tuple[SelectEntityDescription, ...]] = AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH], SELECT_DESC[ThinQProperty.CURRENT_JOB_MODE], ), - DeviceType.DEHUMIDIFIER: (AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH],), + DeviceType.DEHUMIDIFIER: ( + AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH], + SelectEntityDescription( + key=ThinQProperty.CURRENT_JOB_MODE, + translation_key="current_job_mode_dehumidifier", + ), + ), DeviceType.DISH_WASHER: ( OPERATION_SELECT_DESC[ThinQProperty.DISH_WASHER_OPERATION_MODE], ), diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index e1d3779f44b..a5fb81e3818 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -19,7 +19,7 @@ "description": "Please enter a ThinQ [PAT(Personal Access Token)]({pat_url}) created with your LG ThinQ account.", "data": { "access_token": "Personal Access Token", - "country": "Country" + "country": "[%key:common::config_flow::data::country%]" } } } @@ -119,11 +119,11 @@ "fan_mode": { "state": { "slow": "Slow", - "low": "Low", - "mid": "Medium", - "high": "High", + "low": "[%key:common::state::low%]", + "mid": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "power": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]", - "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]" + "auto": "[%key:common::state::auto%]" } }, "preset_mode": { @@ -303,7 +303,7 @@ "state": { "invalid": "Invalid", "weak": "Weak", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "strong": "Strong", "very_strong": "Very strong" } @@ -343,7 +343,7 @@ "growth_mode": { "name": "Mode", "state": { - "standard": "Auto", + "standard": "[%key:common::state::auto%]", "ext_leaf": "Vegetables", "ext_herb": "Herbs", "ext_flower": "Flowers", @@ -353,7 +353,7 @@ "growth_mode_for_location": { "name": "{location} mode", "state": { - "standard": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]", + "standard": "[%key:common::state::auto%]", "ext_leaf": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::ext_leaf%]", "ext_herb": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::ext_herb%]", "ext_flower": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::ext_flower%]", @@ -390,17 +390,17 @@ "temperature_state": { "name": "[%key:component::sensor::entity_component::temperature::name%]", "state": { - "high": "High", + "high": "[%key:common::state::high%]", "normal": "Good", - "low": "Low" + "low": "[%key:common::state::low%]" } }, "temperature_state_for_location": { "name": "[%key:component::lg_thinq::entity::number::target_temperature_for_location::name%]", "state": { - "high": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::high%]", + "high": "[%key:common::state::high%]", "normal": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::normal%]", - "low": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::low%]" + "low": "[%key:common::state::low%]" } }, "current_state": { @@ -581,7 +581,7 @@ "name": "[%key:component::lg_thinq::entity::binary_sensor::one_touch_filter::name%]", "state": { "off": "[%key:common::state::off%]", - "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]", + "auto": "[%key:common::state::auto%]", "power": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]", "replace": "Replace filter", "smart_power": "Smart safe storage", @@ -599,7 +599,7 @@ "name": "Operating mode", "state": { "air_clean": "Purify", - "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]", + "auto": "[%key:common::state::auto%]", "clothes_dry": "Laundry", "edge": "Edge cleaning", "heat_pump": "Heat pump", @@ -607,7 +607,7 @@ "intensive_dry": "Spot", "macro": "Custom mode", "mop": "Mop", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "off": "[%key:common::state::off%]", "quiet_humidity": "Silent", "rapid_humidity": "Jet", @@ -626,7 +626,7 @@ "auto": "Low power", "high": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]", "mop": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::mop%]", - "normal": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::normal%]", + "normal": "[%key:common::state::normal%]", "off": "[%key:common::state::off%]", "turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]" } @@ -649,11 +649,11 @@ "current_dish_washing_course": { "name": "Current cycle", "state": { - "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]", + "auto": "[%key:common::state::auto%]", "heavy": "Intensive", "delicate": "Delicate", "turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "rinse": "Rinse", "refresh": "Refresh", "express": "Express", @@ -781,8 +781,8 @@ "name": "Battery", "state": { "high": "Full", - "mid": "Medium", - "low": "Low", + "mid": "[%key:common::state::medium%]", + "low": "[%key:common::state::low%]", "warning": "Empty" } }, @@ -876,12 +876,12 @@ "name": "Speed", "state": { "slow": "[%key:component::lg_thinq::entity::climate::climate_air_conditioner::state_attributes::fan_mode::state::slow%]", - "low": "Low", - "mid": "Medium", - "high": "High", + "low": "[%key:common::state::low%]", + "mid": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "power": "Turbo", "turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]", - "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]", + "auto": "[%key:common::state::auto%]", "wind_1": "Step 1", "wind_2": "Step 2", "wind_3": "Step 3", @@ -905,7 +905,7 @@ "name": "Operating mode", "state": { "air_clean": "Purifying", - "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]", + "auto": "[%key:common::state::auto%]", "baby_care": "[%key:component::lg_thinq::entity::sensor::personalization_mode::state::baby%]", "circulator": "Booster", "clean": "Single", @@ -928,6 +928,17 @@ "vacation": "Vacation" } }, + "current_job_mode_dehumidifier": { + "name": "[%key:component::lg_thinq::entity::sensor::current_job_mode::name%]", + "state": { + "air_clean": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::air_clean%]", + "clothes_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::clothes_dry%]", + "intensive_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::intensive_dry%]", + "quiet_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::quiet_humidity%]", + "rapid_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::rapid_humidity%]", + "smart_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::smart_humidity%]" + } + }, "operation_mode": { "name": "Operation", "state": { @@ -1005,7 +1016,7 @@ "name": "[%key:component::lg_thinq::entity::binary_sensor::one_touch_filter::name%]", "state": { "off": "[%key:common::state::off%]", - "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]", + "auto": "[%key:common::state::auto%]", "power": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]", "replace": "[%key:component::lg_thinq::entity::sensor::fresh_air_filter::state::replace%]", "smart_power": "[%key:component::lg_thinq::entity::sensor::fresh_air_filter::state::smart_power%]", @@ -1023,5 +1034,10 @@ } } } + }, + "exceptions": { + "failed_to_connect_mqtt": { + "message": "Failed to connect MQTT: {error}" + } } } diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 7b548533058..d2869670ba4 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -442,7 +442,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: brightness += params.pop(ATTR_BRIGHTNESS_STEP) else: - brightness += round(params.pop(ATTR_BRIGHTNESS_STEP_PCT) / 100 * 255) + brightness_pct = round(brightness / 255 * 100) + brightness = round( + (brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255 + ) params[ATTR_BRIGHTNESS] = max(0, min(255, brightness)) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 77c5df88dd3..2cd5921d794 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -293,11 +293,10 @@ turn_on: - light.LightEntityFeature.FLASH selector: select: + translation_key: flash options: - - label: "Long" - value: "long" - - label: "Short" - value: "short" + - long + - short turn_off: target: diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index ecacb3c2daa..7a53f2569e7 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -283,6 +283,12 @@ "yellow": "Yellow", "yellowgreen": "Yellow green" } + }, + "flash": { + "options": { + "short": "Short", + "long": "Long" + } } }, "services": { diff --git a/homeassistant/components/linkplay/__init__.py b/homeassistant/components/linkplay/__init__.py index 918e52a755d..2da73666cc4 100644 --- a/homeassistant/components/linkplay/__init__.py +++ b/homeassistant/components/linkplay/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONTROLLER, CONTROLLER_KEY, DOMAIN, PLATFORMS +from .const import DOMAIN, PLATFORMS, SHARED_DATA, LinkPlaySharedData from .utils import async_get_client_session @@ -44,11 +44,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: LinkPlayConfigEntry) -> # setup the controller and discover multirooms controller: LinkPlayController | None = None hass.data.setdefault(DOMAIN, {}) - if CONTROLLER not in hass.data[DOMAIN]: + if SHARED_DATA not in hass.data[DOMAIN]: controller = LinkPlayController(session) - hass.data[DOMAIN][CONTROLLER_KEY] = controller + hass.data[DOMAIN][SHARED_DATA] = LinkPlaySharedData(controller, {}) else: - controller = hass.data[DOMAIN][CONTROLLER_KEY] + controller = hass.data[DOMAIN][SHARED_DATA].controller await controller.add_bridge(bridge) await controller.discover_multirooms() @@ -62,4 +62,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: LinkPlayConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: LinkPlayConfigEntry) -> bool: """Unload a config entry.""" + # remove the bridge from the controller and discover multirooms + bridge: LinkPlayBridge | None = entry.runtime_data.bridge + controller: LinkPlayController = hass.data[DOMAIN][SHARED_DATA].controller + await controller.remove_bridge(bridge) + await controller.discover_multirooms() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/linkplay/const.py b/homeassistant/components/linkplay/const.py index e10450cf255..74b87f4aae9 100644 --- a/homeassistant/components/linkplay/const.py +++ b/homeassistant/components/linkplay/const.py @@ -1,12 +1,23 @@ """LinkPlay constants.""" +from dataclasses import dataclass + from linkplay.controller import LinkPlayController from homeassistant.const import Platform from homeassistant.util.hass_dict import HassKey + +@dataclass +class LinkPlaySharedData: + """Shared data for LinkPlay.""" + + controller: LinkPlayController + entity_to_bridge: dict[str, str] + + DOMAIN = "linkplay" -CONTROLLER = "controller" -CONTROLLER_KEY: HassKey[LinkPlayController] = HassKey(CONTROLLER) +SHARED_DATA = "shared_data" +SHARED_DATA_KEY: HassKey[LinkPlaySharedData] = HassKey(SHARED_DATA) PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER] DATA_SESSION = "session" diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index 02acd0f04f4..69a7b71eeb6 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.2.2"], + "requirements": ["python-linkplay==0.2.4"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 67aa424e3a2..89cc498ed01 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -23,19 +23,14 @@ from homeassistant.components.media_player import ( RepeatMode, async_process_play_media_url, ) -from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import ( - config_validation as cv, - entity_platform, - entity_registry as er, -) +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow -from . import LinkPlayConfigEntry, LinkPlayData -from .const import CONTROLLER_KEY, DOMAIN +from . import SHARED_DATA, LinkPlayConfigEntry +from .const import DOMAIN from .entity import LinkPlayBaseEntity, exception_wrap _LOGGER = logging.getLogger(__name__) @@ -163,6 +158,13 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): mode.value for mode in bridge.player.available_equalizer_modes ] + async def async_added_to_hass(self) -> None: + """Handle common setup when added to hass.""" + await super().async_added_to_hass() + self.hass.data[DOMAIN][SHARED_DATA].entity_to_bridge[self.entity_id] = ( + self._bridge.device.uuid + ) + @exception_wrap async def async_update(self) -> None: """Update the state of the media player.""" @@ -276,62 +278,63 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): async def async_join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" - controller: LinkPlayController = self.hass.data[DOMAIN][CONTROLLER_KEY] + controller: LinkPlayController = self.hass.data[DOMAIN][SHARED_DATA].controller multiroom = self._bridge.multiroom if multiroom is None: multiroom = LinkPlayMultiroom(self._bridge) for group_member in group_members: - bridge = self._get_linkplay_bridge(group_member) + bridge = await self._get_linkplay_bridge(group_member) if bridge: await multiroom.add_follower(bridge) await controller.discover_multirooms() - def _get_linkplay_bridge(self, entity_id: str) -> LinkPlayBridge: + async def _get_linkplay_bridge(self, entity_id: str) -> LinkPlayBridge: """Get linkplay bridge from entity_id.""" - entity_registry = er.async_get(self.hass) + shared_data = self.hass.data[DOMAIN][SHARED_DATA] + controller = shared_data.controller + bridge_uuid = shared_data.entity_to_bridge.get(entity_id, None) + bridge = await controller.find_bridge(bridge_uuid) - # Check for valid linkplay media_player entity - entity_entry = entity_registry.async_get(entity_id) - - if ( - entity_entry is None - or entity_entry.domain != Platform.MEDIA_PLAYER - or entity_entry.platform != DOMAIN - or entity_entry.config_entry_id is None - ): + if bridge is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_grouping_entity", translation_placeholders={"entity_id": entity_id}, ) - config_entry = self.hass.config_entries.async_get_entry( - entity_entry.config_entry_id - ) - assert config_entry - - # Return bridge - data: LinkPlayData = config_entry.runtime_data - return data.bridge + return bridge @property def group_members(self) -> list[str]: """List of players which are grouped together.""" multiroom = self._bridge.multiroom - if multiroom is not None: - return [multiroom.leader.device.uuid] + [ - follower.device.uuid for follower in multiroom.followers - ] + if multiroom is None: + return [] - return [] + shared_data = self.hass.data[DOMAIN][SHARED_DATA] + + return [ + entity_id + for entity_id, bridge in shared_data.entity_to_bridge.items() + if bridge + in [multiroom.leader.device.uuid] + + [follower.device.uuid for follower in multiroom.followers] + ] + + @property + def media_image_url(self) -> str | None: + """Image url of playing media.""" + if self._bridge.player.status in [PlayingStatus.PLAYING, PlayingStatus.PAUSED]: + return str(self._bridge.player.album_art) + return None @exception_wrap async def async_unjoin_player(self) -> None: """Remove this player from any group.""" - controller: LinkPlayController = self.hass.data[DOMAIN][CONTROLLER_KEY] + controller: LinkPlayController = self.hass.data[DOMAIN][SHARED_DATA].controller multiroom = self._bridge.multiroom if multiroom is not None: diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index ca9af22f1e9..d4df011d0aa 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Generic -from pylitterbot import LitterRobot, Robot +from pylitterbot import LitterRobot, LitterRobot4, Robot from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -47,6 +47,15 @@ BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, . is_on_fn=lambda robot: robot.sleep_mode_enabled, ), ), + LitterRobot4: ( + RobotBinarySensorEntityDescription[LitterRobot4]( + key="hopper_connected", + translation_key="hopper_connected", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_registry_enabled_default=False, + is_on_fn=lambda robot: not robot.is_hopper_removed, + ), + ), Robot: ( # type: ignore[type-abstract] # only used for isinstance check RobotBinarySensorEntityDescription[Robot]( key="power_status", diff --git a/homeassistant/components/litterrobot/icons.json b/homeassistant/components/litterrobot/icons.json index ba3df2114b7..163ad80c0a8 100644 --- a/homeassistant/components/litterrobot/icons.json +++ b/homeassistant/components/litterrobot/icons.json @@ -6,6 +6,9 @@ }, "sleep_mode": { "default": "mdi:sleep" + }, + "hopper_connected": { + "default": "mdi:filter-check" } }, "button": { @@ -32,6 +35,19 @@ "default": "mdi:scale" } }, + "sensor": { + "hopper_status": { + "default": "mdi:filter", + "state": { + "disabled": "mdi:filter-remove", + "empty": "mdi:filter-minus-outline", + "enabled": "mdi:filter-check", + "motor_disconnected": "mdi:engine-off", + "motor_fault_short": "mdi:flash-off", + "motor_ot_amps": "mdi:flash-alert" + } + } + }, "switch": { "night_light_mode": { "default": "mdi:lightbulb-off", diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index a638f24cf2a..cdd9a1c08a5 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -57,9 +57,9 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { translation_key="sleep_mode_start_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=( - lambda robot: robot.sleep_mode_start_time - if robot.sleep_mode_enabled - else None + lambda robot: ( + robot.sleep_mode_start_time if robot.sleep_mode_enabled else None + ) ), ), RobotSensorEntityDescription[LitterRobot]( @@ -67,9 +67,9 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { translation_key="sleep_mode_end_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=( - lambda robot: robot.sleep_mode_end_time - if robot.sleep_mode_enabled - else None + lambda robot: ( + robot.sleep_mode_end_time if robot.sleep_mode_enabled else None + ) ), ), RobotSensorEntityDescription[LitterRobot]( @@ -117,6 +117,24 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { ), ], LitterRobot4: [ + RobotSensorEntityDescription[LitterRobot4]( + key="hopper_status", + translation_key="hopper_status", + device_class=SensorDeviceClass.ENUM, + options=[ + "enabled", + "disabled", + "motor_fault_short", + "motor_ot_amps", + "motor_disconnected", + "empty", + ], + value_fn=( + lambda robot: ( + status.name.lower() if (status := robot.hopper_status) else None + ) + ), + ), RobotSensorEntityDescription[LitterRobot4]( key="litter_level", translation_key="litter_level", diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 052427f3032..ba5472918d3 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -34,6 +34,9 @@ }, "entity": { "binary_sensor": { + "hopper_connected": { + "name": "Hopper connected" + }, "sleeping": { "name": "Sleeping" }, @@ -59,6 +62,17 @@ "food_level": { "name": "Food level" }, + "hopper_status": { + "name": "Hopper status", + "state": { + "enabled": "[%key:common::state::enabled%]", + "disabled": "[%key:common::state::disabled%]", + "motor_fault_short": "Motor shorted", + "motor_ot_amps": "Motor overtorqued", + "motor_disconnected": "Motor disconnected", + "empty": "Empty" + } + }, "last_seen": { "name": "Last seen" }, @@ -93,7 +107,7 @@ "hpf": "Home position fault", "off": "[%key:common::state::off%]", "offline": "Offline", - "otf": "Over torque fault", + "otf": "Overtorque fault", "p": "[%key:common::state::paused%]", "pd": "Pinch detect", "pwrd": "Powering down", @@ -118,9 +132,9 @@ "brightness_level": { "name": "Panel brightness", "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } }, diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index df6f994a46c..639cf5234d1 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -89,20 +89,27 @@ class LocalCalendarEntity(CalendarEntity): self, hass: HomeAssistant, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all events in a specific time frame.""" - events = self._calendar.timeline_tz(start_date.tzinfo).overlapping( - start_date, - end_date, - ) - return [_get_calendar_event(event) for event in events] + + def events_in_range() -> list[CalendarEvent]: + events = self._calendar.timeline_tz(start_date.tzinfo).overlapping( + start_date, + end_date, + ) + return [_get_calendar_event(event) for event in events] + + return await self.hass.async_add_executor_job(events_in_range) async def async_update(self) -> None: """Update entity state with the next upcoming event.""" - now = dt_util.now() - events = self._calendar.timeline_tz(now.tzinfo).active_after(now) - if event := next(events, None): - self._event = _get_calendar_event(event) - else: - self._event = None + + def next_event() -> CalendarEvent | None: + now = dt_util.now() + events = self._calendar.timeline_tz(now.tzinfo).active_after(now) + if event := next(events, None): + return _get_calendar_event(event) + return None + + self._event = await self.hass.async_add_executor_job(next_event) async def _async_store(self) -> None: """Persist the calendar to disk.""" diff --git a/homeassistant/components/local_calendar/config_flow.py b/homeassistant/components/local_calendar/config_flow.py index fef45f786f9..f5b3220fb8c 100644 --- a/homeassistant/components/local_calendar/config_flow.py +++ b/homeassistant/components/local_calendar/config_flow.py @@ -97,8 +97,7 @@ class LocalCalendarConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_ICS_FILE], self.data[CONF_STORAGE_KEY], ) - except HomeAssistantError as err: - _LOGGER.debug("Error saving uploaded file: %s", err) + except InvalidIcsFile: errors[CONF_ICS_FILE] = "invalid_ics_file" else: return self.async_create_entry( @@ -112,6 +111,10 @@ class LocalCalendarConfigFlow(ConfigFlow, domain=DOMAIN): ) +class InvalidIcsFile(HomeAssistantError): + """Error to indicate that the uploaded file is not a valid ICS file.""" + + def save_uploaded_ics_file( hass: HomeAssistant, uploaded_file_id: str, storage_key: str ): @@ -122,6 +125,10 @@ def save_uploaded_ics_file( try: CalendarStream.from_ics(ics) except CalendarParseError as err: - raise HomeAssistantError("Failed to upload file: Invalid ICS file") from err + _LOGGER.error("Error reading the calendar information: %s", err.message) + _LOGGER.debug( + "Additional calendar error detail: %s", str(err.detailed_error) + ) + raise InvalidIcsFile("Failed to upload file: Invalid ICS file") from err dest_path = Path(hass.config.path(STORAGE_PATH.format(key=storage_key))) shutil.move(file, dest_path) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 90cd5a6d2ac..eba26e88d5a 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==9.1.0"] + "requirements": ["ical==9.2.0"] } diff --git a/homeassistant/components/local_calendar/strings.json b/homeassistant/components/local_calendar/strings.json index 2b61fc9ab3e..6d68b46b5b0 100644 --- a/homeassistant/components/local_calendar/strings.json +++ b/homeassistant/components/local_calendar/strings.json @@ -17,7 +17,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" }, "error": { - "invalid_ics_file": "Invalid .ics file" + "invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details." } }, "selector": { diff --git a/homeassistant/components/local_file/camera.py b/homeassistant/components/local_file/camera.py index 8be0389678d..4544f69dbee 100644 --- a/homeassistant/components/local_file/camera.py +++ b/homeassistant/components/local_file/camera.py @@ -7,38 +7,19 @@ import mimetypes import voluptuous as vol -from homeassistant.components.camera import ( - PLATFORM_SCHEMA as CAMERA_PLATFORM_SCHEMA, - Camera, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.components.camera import Camera +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_FILE_PATH, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import ( - config_validation as cv, - entity_platform, - issue_registry as ir, -) -from homeassistant.helpers.entity_platform import ( - AddConfigEntryEntitiesCallback, - AddEntitiesCallback, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify +from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DEFAULT_NAME, DOMAIN, SERVICE_UPDATE_FILE_PATH +from .const import SERVICE_UPDATE_FILE_PATH from .util import check_file_path_access _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = CAMERA_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_FILE_PATH): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - async def async_setup_entry( hass: HomeAssistant, @@ -67,57 +48,6 @@ async def async_setup_entry( ) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Camera that works with local files.""" - file_path: str = config[CONF_FILE_PATH] - file_path_slug = slugify(file_path) - - if not await hass.async_add_executor_job(check_file_path_access, file_path): - ir.async_create_issue( - hass, - DOMAIN, - f"no_access_path_{file_path_slug}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - learn_more_url="https://www.home-assistant.io/integrations/local_file/", - severity=ir.IssueSeverity.WARNING, - translation_key="no_access_path", - translation_placeholders={ - "file_path": file_path_slug, - }, - ) - return - - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - issue_domain=DOMAIN, - learn_more_url="https://www.home-assistant.io/integrations/local_file/", - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Local file", - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - class LocalFile(Camera): """Representation of a local file camera.""" diff --git a/homeassistant/components/local_file/config_flow.py b/homeassistant/components/local_file/config_flow.py index 36a41c03543..c4b83f9407a 100644 --- a/homeassistant/components/local_file/config_flow.py +++ b/homeassistant/components/local_file/config_flow.py @@ -50,18 +50,12 @@ DATA_SCHEMA_SETUP = vol.Schema( CONFIG_FLOW = { "user": SchemaFlowFormStep( - schema=DATA_SCHEMA_SETUP, - validate_user_input=validate_options, - ), - "import": SchemaFlowFormStep( - schema=DATA_SCHEMA_SETUP, - validate_user_input=validate_options, - ), + schema=DATA_SCHEMA_SETUP, validate_user_input=validate_options + ) } OPTIONS_FLOW = { "init": SchemaFlowFormStep( - DATA_SCHEMA_OPTIONS, - validate_user_input=validate_options, + DATA_SCHEMA_OPTIONS, validate_user_input=validate_options ) } diff --git a/homeassistant/components/local_file/strings.json b/homeassistant/components/local_file/strings.json index 393cc5f2e46..ebf4c9d7fbf 100644 --- a/homeassistant/components/local_file/strings.json +++ b/homeassistant/components/local_file/strings.json @@ -53,11 +53,5 @@ "file_path_not_accessible": { "message": "Path {file_path} is not accessible" } - }, - "issues": { - "no_access_path": { - "title": "Incorrect file path", - "description": "While trying to import your configuration the provided file path {file_path} could not be read.\nPlease update your configuration to a correct file path and restart to fix this issue." - } } } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index a630c18c669..fb48ca72337 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==9.1.0"] + "requirements": ["ical==9.2.0"] } diff --git a/homeassistant/components/locative/strings.json b/homeassistant/components/locative/strings.json index 7cc53f18428..9d6c07ee442 100644 --- a/homeassistant/components/locative/strings.json +++ b/homeassistant/components/locative/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up the Locative Webhook", + "title": "Set up the Locative webhook", "description": "[%key:common::config_flow::description::confirm_setup%]" } }, diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index fd8636acf97..fd2854b7932 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -28,7 +28,7 @@ "locked": "[%key:common::state::locked%]", "locking": "Locking", "open": "[%key:common::state::open%]", - "opening": "Opening", + "opening": "[%key:common::state::opening%]", "unlocked": "[%key:common::state::unlocked%]", "unlocking": "Unlocking" }, diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index 40b904c1279..f27a470a23d 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast +from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast, final from propcache.api import cached_property from sqlalchemy.engine.row import Row @@ -114,6 +114,7 @@ DATA_POS: Final = 11 CONTEXT_POS: Final = 12 +@final # Final to allow direct checking of the type instead of using isinstance class EventAsRow(NamedTuple): """Convert an event to a row. diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index e3d0d8a29fa..4b767f66d69 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -47,7 +47,7 @@ class LogbookLiveStream: subscriptions: list[CALLBACK_TYPE] end_time_unsub: CALLBACK_TYPE | None = None task: asyncio.Task | None = None - wait_sync_task: asyncio.Task | None = None + wait_sync_future: asyncio.Future[None] | None = None @callback @@ -329,8 +329,8 @@ async def ws_event_stream( subscriptions.clear() if live_stream.task: live_stream.task.cancel() - if live_stream.wait_sync_task: - live_stream.wait_sync_task.cancel() + if live_stream.wait_sync_future: + live_stream.wait_sync_future.cancel() if live_stream.end_time_unsub: live_stream.end_time_unsub() live_stream.end_time_unsub = None @@ -399,10 +399,12 @@ async def ws_event_stream( ) ) - live_stream.wait_sync_task = create_eager_task( - get_instance(hass).async_block_till_done() - ) - await live_stream.wait_sync_task + if sync_future := get_instance(hass).async_get_commit_future(): + # Set the future so we can cancel it if the client + # unsubscribes before the commit is done so we don't + # query the database needlessly + live_stream.wait_sync_future = sync_future + await live_stream.wait_sync_future # # Fetch any events from the database that have diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index 15283b246b2..8593b3c478e 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -24,8 +24,10 @@ from .const import ( SERVICE_SET_LEVEL, ) from .helpers import ( + DATA_LOGGER, LoggerDomainConfig, LoggerSettings, + _clear_logger_overwrites, # noqa: F401 set_default_log_level, set_log_levels, ) @@ -54,7 +56,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: settings = LoggerSettings(hass, config) - domain_config = hass.data[DOMAIN] = LoggerDomainConfig({}, settings) + domain_config = hass.data[DATA_LOGGER] = LoggerDomainConfig({}, settings) logging.setLoggerClass(_get_logger_class(domain_config.overrides)) websocket_api.async_load_websocket_api(hass) diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index 00cea7e8aa5..19afe18e3fe 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -9,13 +9,14 @@ from dataclasses import asdict, dataclass from enum import StrEnum from functools import lru_cache import logging -from typing import Any, cast +from typing import Any from homeassistant.const import EVENT_LOGGING_CHANGED from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.loader import IntegrationNotFound, async_get_integration +from homeassistant.util.hass_dict import HassKey from .const import ( DOMAIN, @@ -28,6 +29,8 @@ from .const import ( STORAGE_VERSION, ) +DATA_LOGGER: HassKey[LoggerDomainConfig] = HassKey(DOMAIN) + SAVE_DELAY = 15.0 # At startup, we want to save after a long delay to avoid # saving while the system is still starting up. If the system @@ -39,12 +42,6 @@ SAVE_DELAY = 15.0 SAVE_DELAY_LONG = 180.0 -@callback -def async_get_domain_config(hass: HomeAssistant) -> LoggerDomainConfig: - """Return the domain config.""" - return cast(LoggerDomainConfig, hass.data[DOMAIN]) - - @callback def set_default_log_level(hass: HomeAssistant, level: int) -> None: """Set the default log level for components.""" @@ -55,7 +52,7 @@ def set_default_log_level(hass: HomeAssistant, level: int) -> None: @callback def set_log_levels(hass: HomeAssistant, logpoints: Mapping[str, int]) -> None: """Set the specified log levels.""" - async_get_domain_config(hass).overrides.update(logpoints) + hass.data[DATA_LOGGER].overrides.update(logpoints) for key, value in logpoints.items(): _set_log_level(logging.getLogger(key), value) hass.bus.async_fire(EVENT_LOGGING_CHANGED) @@ -78,6 +75,12 @@ def _chattiest_log_level(level1: int, level2: int) -> int: return min(level1, level2) +@callback +def _clear_logger_overwrites(hass: HomeAssistant) -> None: + """Clear logger overwrites. Used for testing.""" + hass.data[DATA_LOGGER].overrides.clear() + + async def get_integration_loggers(hass: HomeAssistant, domain: str) -> set[str]: """Get loggers for an integration.""" loggers: set[str] = {f"homeassistant.components.{domain}"} diff --git a/homeassistant/components/logger/websocket_api.py b/homeassistant/components/logger/websocket_api.py index 2430f187a6f..041fe417698 100644 --- a/homeassistant/components/logger/websocket_api.py +++ b/homeassistant/components/logger/websocket_api.py @@ -12,10 +12,10 @@ from homeassistant.setup import async_get_loaded_integrations from .const import LOGSEVERITY from .helpers import ( + DATA_LOGGER, LoggerSetting, LogPersistance, LogSettingsType, - async_get_domain_config, get_logger, ) @@ -68,7 +68,7 @@ async def handle_integration_log_level( msg["id"], websocket_api.ERR_NOT_FOUND, "Integration not found" ) return - await async_get_domain_config(hass).settings.async_update( + await hass.data[DATA_LOGGER].settings.async_update( hass, msg["integration"], LoggerSetting( @@ -93,7 +93,7 @@ async def handle_module_log_level( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle setting integration log level.""" - await async_get_domain_config(hass).settings.async_update( + await hass.data[DATA_LOGGER].settings.async_update( hass, msg["module"], LoggerSetting( diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index 82bdfad4774..8d3da47795a 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/lutron", "iot_class": "local_polling", "loggers": ["pylutron"], - "requirements": ["pylutron==0.2.16"], + "requirements": ["pylutron==0.2.18"], "single_config_entry": true } diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index 45e7a04bdc9..115da5cb101 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -123,7 +123,8 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN): assets = None try: assets = await async_pair(self.data[CONF_HOST]) - except (TimeoutError, OSError): + except (TimeoutError, OSError) as exc: + _LOGGER.debug("Pairing failed", exc_info=exc) errors["base"] = "cannot_connect" if not errors: diff --git a/homeassistant/components/mailgun/strings.json b/homeassistant/components/mailgun/strings.json index 0c44dc63aae..e962dedd273 100644 --- a/homeassistant/components/mailgun/strings.json +++ b/homeassistant/components/mailgun/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up the Mailgun Webhook", + "title": "Set up the Mailgun webhook", "description": "Are you sure you want to set up Mailgun?" } }, @@ -12,7 +12,7 @@ "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, "create_entry": { - "default": "To send events to Home Assistant, you will need to set up [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + "default": "To send events to Home Assistant, you will need to set up a [webhook with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." } } } diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index b173a2c850b..6cab2c39c97 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["matrix_client"], "quality_scale": "legacy", - "requirements": ["matrix-nio==0.25.2", "Pillow==11.1.0"] + "requirements": ["matrix-nio==0.25.2", "Pillow==11.2.1"] } diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index b5665e5d47a..95375d5fc49 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -265,4 +265,73 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterBinarySensor, required_attributes=(clusters.SmokeCoAlarm.Attributes.InterconnectCOAlarm,), ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="EnergyEvseChargingStatusSensor", + translation_key="evse_charging_status", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + measurement_to_ha={ + clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInCharging: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDischarging: False, + clusters.EnergyEvse.Enums.StateEnum.kSessionEnding: False, + clusters.EnergyEvse.Enums.StateEnum.kFault: False, + }.get, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.EnergyEvse.Attributes.State,), + allow_multi=True, # also used for sensor entity + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="EnergyEvsePlugStateSensor", + translation_key="evse_plug_state", + device_class=BinarySensorDeviceClass.PLUG, + measurement_to_ha={ + clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInCharging: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDischarging: True, + clusters.EnergyEvse.Enums.StateEnum.kSessionEnding: False, + clusters.EnergyEvse.Enums.StateEnum.kFault: False, + }.get, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.EnergyEvse.Attributes.State,), + allow_multi=True, # also used for sensor entity + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="EnergyEvseSupplyStateSensor", + translation_key="evse_supply_charging_state", + device_class=BinarySensorDeviceClass.RUNNING, + measurement_to_ha={ + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kChargingEnabled: True, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDischargingEnabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabledDiagnostics: False, + }.get, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.EnergyEvse.Attributes.SupplyState,), + allow_multi=True, # also used for sensor entity + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="WaterHeaterManagementBoostStateSensor", + translation_key="boost_state", + measurement_to_ha=lambda x: ( + x == clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive + ), + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.WaterHeaterManagement.Attributes.BoostState,), + ), ] diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 7102b693e45..8042b7505f4 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -27,6 +27,7 @@ from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS from .update import DISCOVERY_SCHEMAS as UPDATE_SCHEMAS from .vacuum import DISCOVERY_SCHEMAS as VACUUM_SCHEMAS from .valve import DISCOVERY_SCHEMAS as VALVE_SCHEMAS +from .water_heater import DISCOVERY_SCHEMAS as WATER_HEATER_SCHEMAS DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, @@ -44,6 +45,7 @@ DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.UPDATE: UPDATE_SCHEMAS, Platform.VACUUM: VACUUM_SCHEMAS, Platform.VALVE: VALVE_SCHEMAS, + Platform.WATER_HEATER: WATER_HEATER_SCHEMAS, } SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 96696193466..fded57d34f5 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -61,6 +61,7 @@ class MatterEntityDescription(EntityDescription): # convert the value from the primary attribute to the value used by HA measurement_to_ha: Callable[[Any], Any] | None = None ha_to_native_value: Callable[[Any], Any] | None = None + command_timeout: int | None = None class MatterEntity(Entity): diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index f9217cabcc4..82e45e0383a 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -66,11 +66,26 @@ "operational_state": { "default": "mdi:play-pause" }, + "tank_volume": { + "default": "mdi:water-boiler" + }, + "tank_percentage": { + "default": "mdi:water-boiler" + }, "valve_position": { "default": "mdi:valve" }, "battery_replacement_description": { "default": "mdi:battery-sync-outline" + }, + "evse_state": { + "default": "mdi:ev-station" + }, + "evse_supply_state": { + "default": "mdi:ev-station" + }, + "evse_fault_state": { + "default": "mdi:ev-station" } }, "switch": { @@ -80,6 +95,9 @@ "on": "mdi:lock", "off": "mdi:lock-off" } + }, + "evse_charging_switch": { + "default": "mdi:ev-station" } } } diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index e78c34391cd..6e77be93705 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -41,6 +41,7 @@ type SelectCluster = ( | clusters.DishwasherMode | clusters.EnergyEvseMode | clusters.DeviceEnergyManagementMode + | clusters.WaterHeaterMode ) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 10f8db275f5..f1704b45c50 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -37,6 +37,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfTemperature, + UnitOfVolume, UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant, callback @@ -65,7 +66,6 @@ CONTAMINATION_STATE_MAP = { clusters.SmokeCoAlarm.Enums.ContaminationStateEnum.kCritical: "critical", } - OPERATIONAL_STATE_MAP = { # enum with known Operation state values which we can translate clusters.OperationalState.Enums.OperationalStateEnum.kStopped: "stopped", @@ -77,6 +77,31 @@ OPERATIONAL_STATE_MAP = { clusters.RvcOperationalState.Enums.OperationalStateEnum.kDocked: "docked", } +BOOST_STATE_MAP = { + clusters.WaterHeaterManagement.Enums.BoostStateEnum.kInactive: "inactive", + clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive: "active", + clusters.WaterHeaterManagement.Enums.BoostStateEnum.kUnknownEnumValue: None, +} + +EVSE_FAULT_STATE_MAP = { + clusters.EnergyEvse.Enums.FaultStateEnum.kNoError: "no_error", + clusters.EnergyEvse.Enums.FaultStateEnum.kMeterFailure: "meter_failure", + clusters.EnergyEvse.Enums.FaultStateEnum.kOverVoltage: "over_voltage", + clusters.EnergyEvse.Enums.FaultStateEnum.kUnderVoltage: "under_voltage", + clusters.EnergyEvse.Enums.FaultStateEnum.kOverCurrent: "over_current", + clusters.EnergyEvse.Enums.FaultStateEnum.kContactWetFailure: "contact_wet_failure", + clusters.EnergyEvse.Enums.FaultStateEnum.kContactDryFailure: "contact_dry_failure", + clusters.EnergyEvse.Enums.FaultStateEnum.kPowerLoss: "power_loss", + clusters.EnergyEvse.Enums.FaultStateEnum.kPowerQuality: "power_quality", + clusters.EnergyEvse.Enums.FaultStateEnum.kPilotShortCircuit: "pilot_short_circuit", + clusters.EnergyEvse.Enums.FaultStateEnum.kEmergencyStop: "emergency_stop", + clusters.EnergyEvse.Enums.FaultStateEnum.kEVDisconnected: "ev_disconnected", + clusters.EnergyEvse.Enums.FaultStateEnum.kWrongPowerSupply: "wrong_power_supply", + clusters.EnergyEvse.Enums.FaultStateEnum.kLiveNeutralSwap: "live_neutral_swap", + clusters.EnergyEvse.Enums.FaultStateEnum.kOverTemperature: "over_temperature", + clusters.EnergyEvse.Enums.FaultStateEnum.kOther: "other", +} + async def async_setup_entry( hass: HomeAssistant, @@ -904,4 +929,117 @@ DISCOVERY_SCHEMAS = [ # don't discover this entry if the supported state list is empty secondary_value_is_not=[], ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseFaultState", + translation_key="evse_fault_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=list(EVSE_FAULT_STATE_MAP.values()), + measurement_to_ha=EVSE_FAULT_STATE_MAP.get, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.FaultState,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseCircuitCapacity", + translation_key="evse_circuit_capacity", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.CircuitCapacity,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseMinimumChargeCurrent", + translation_key="evse_min_charge_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.MinimumChargeCurrent,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseMaximumChargeCurrent", + translation_key="evse_max_charge_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.MaximumChargeCurrent,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseUserMaximumChargeCurrent", + translation_key="evse_user_max_charge_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.UserMaximumChargeCurrent,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="WaterHeaterManagementTankVolume", + translation_key="tank_volume", + device_class=SensorDeviceClass.VOLUME_STORAGE, + native_unit_of_measurement=UnitOfVolume.LITERS, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.WaterHeaterManagement.Attributes.TankVolume,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="WaterHeaterManagementTankPercentage", + translation_key="tank_percentage", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.WaterHeaterManagement.Attributes.TankPercentage,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="WaterHeaterManagementEstimatedHeatRequired", + translation_key="estimated_heat_required", + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfEnergy.MILLIWATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=3, + state_class=SensorStateClass.TOTAL, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.WaterHeaterManagement.Attributes.EstimatedHeatRequired, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 1404d0a9076..b8e8c63502c 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -76,6 +76,18 @@ }, "muted": { "name": "Muted" + }, + "evse_charging_status": { + "name": "Charging status" + }, + "evse_plug": { + "name": "Plug state" + }, + "evse_supply_charging_state": { + "name": "Supply charging state" + }, + "boost_state": { + "name": "Boost state" } }, "button": { @@ -135,10 +147,10 @@ "state_attributes": { "preset_mode": { "state": { - "low": "Low", - "medium": "Medium", - "high": "High", - "auto": "Auto", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "auto": "[%key:common::state::auto%]", "natural_wind": "Natural wind", "sleep_wind": "Sleep wind" } @@ -160,7 +172,7 @@ "name": "On/Off transition time" }, "altitude": { - "name": "Altitude above Sea Level" + "name": "Altitude above sea level" }, "temperature_offset": { "name": "Temperature offset" @@ -189,9 +201,9 @@ "sensitivity_level": { "name": "Sensitivity", "state": { - "low": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::low%]", + "low": "[%key:common::state::low%]", "standard": "Standard", - "high": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::high%]" + "high": "[%key:common::state::high%]" } }, "startup_on_off": { @@ -213,13 +225,16 @@ "name": "Number of rinses", "state": { "off": "[%key:common::state::off%]", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "extra": "Extra", "max": "Max" } }, "laundry_washer_spin_speed": { "name": "Spin speed" + }, + "water_heater_mode": { + "name": "Water heater mode" } }, "sensor": { @@ -229,8 +244,8 @@ "contamination_state": { "name": "Contamination state", "state": { - "normal": "Normal", - "low": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::low%]", + "normal": "[%key:common::state::normal%]", + "low": "[%key:common::state::low%]", "warning": "Warning", "critical": "Critical" } @@ -258,10 +273,10 @@ "operational_state": { "name": "Operational state", "state": { - "stopped": "Stopped", + "stopped": "[%key:common::state::stopped%]", "running": "Running", "paused": "[%key:common::state::paused%]", - "error": "Error", + "error": "[%key:common::state::error%]", "seeking_charger": "Seeking charger", "charging": "[%key:common::state::charging%]", "docked": "Docked" @@ -270,6 +285,15 @@ "switch_current_position": { "name": "Current switch position" }, + "estimated_heat_required": { + "name": "Required heating energy" + }, + "tank_volume": { + "name": "Tank volume" + }, + "tank_percentage": { + "name": "Hot water level" + }, "valve_position": { "name": "Valve position" }, @@ -278,6 +302,42 @@ }, "current_phase": { "name": "Current phase" + }, + "evse_fault_state": { + "name": "Fault state", + "state": { + "no_error": "OK", + "meter_failure": "Meter failure", + "over_voltage": "Overvoltage", + "under_voltage": "Undervoltage", + "over_current": "Overcurrent", + "contact_wet_failure": "Contact wet failure", + "contact_dry_failure": "Contact dry failure", + "power_loss": "Power loss", + "power_quality": "Power quality", + "pilot_short_circuit": "Pilot short circuit", + "emergency_stop": "Emergency stop", + "ev_disconnected": "EV disconnected", + "wrong_power_supply": "Wrong power supply", + "live_neutral_swap": "Live/neutral swap", + "over_temperature": "Overtemperature", + "other": "Other fault" + } + }, + "evse_circuit_capacity": { + "name": "Circuit capacity" + }, + "evse_charge_current": { + "name": "Charge current" + }, + "evse_min_charge_current": { + "name": "Min charge current" + }, + "evse_max_charge_current": { + "name": "Max charge current" + }, + "evse_user_max_charge_current": { + "name": "User max charge current" } }, "switch": { @@ -289,6 +349,9 @@ }, "child_lock": { "name": "Child lock" + }, + "evse_charging_switch": { + "name": "Enable charging" } }, "vacuum": { @@ -300,6 +363,11 @@ "valve": { "name": "[%key:component::valve::title%]" } + }, + "water_heater": { + "water_heater": { + "name": "[%key:component::water_heater::title%]" + } } }, "issues": { diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index af4803af9a1..870a9098492 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -2,10 +2,12 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import Any from chip.clusters import Objects as clusters +from chip.clusters.Objects import ClusterCommand, NullValue from matter_server.client.models import device_types from homeassistant.components.switch import ( @@ -22,6 +24,13 @@ from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema +EVSE_SUPPLY_STATE_MAP = { + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kChargingEnabled: True, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDischargingEnabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabledDiagnostics: False, +} + async def async_setup_entry( hass: HomeAssistant, @@ -58,6 +67,66 @@ class MatterSwitch(MatterEntity, SwitchEntity): ) +class MatterGenericCommandSwitch(MatterSwitch): + """Representation of a Matter switch.""" + + entity_description: MatterGenericCommandSwitchEntityDescription + + _platform_translation_key = "switch" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn switch on.""" + if self.entity_description.on_command: + # custom command defined to set the new value + await self.send_device_command( + self.entity_description.on_command(), + self.entity_description.command_timeout, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn switch off.""" + if self.entity_description.off_command: + await self.send_device_command( + self.entity_description.off_command(), + self.entity_description.command_timeout, + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + if value_convert := self.entity_description.measurement_to_ha: + value = value_convert(value) + self._attr_is_on = value + + async def send_device_command( + self, + command: ClusterCommand, + command_timeout: int | None = None, + **kwargs: Any, + ) -> None: + """Send device command with timeout.""" + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=command, + timed_request_timeout_ms=command_timeout, + **kwargs, + ) + + +@dataclass(frozen=True) +class MatterGenericCommandSwitchEntityDescription( + SwitchEntityDescription, MatterEntityDescription +): + """Describe Matter Generic command Switch entities.""" + + # command: a custom callback to create the command to send to the device + on_command: Callable[[], Any] | None = None + off_command: Callable[[], Any] | None = None + command_timeout: int | None = None + + @dataclass(frozen=True) class MatterNumericSwitchEntityDescription( SwitchEntityDescription, MatterEntityDescription @@ -194,4 +263,26 @@ DISCOVERY_SCHEMAS = [ ), vendor_id=(4874,), ), + MatterDiscoverySchema( + platform=Platform.SWITCH, + entity_description=MatterGenericCommandSwitchEntityDescription( + key="EnergyEvseChargingSwitch", + translation_key="evse_charging_switch", + on_command=lambda: clusters.EnergyEvse.Commands.EnableCharging( + chargingEnabledUntil=NullValue, + minimumChargeCurrent=0, + maximumChargeCurrent=0, + ), + off_command=clusters.EnergyEvse.Commands.Disable, + command_timeout=3000, + measurement_to_ha=EVSE_SUPPLY_STATE_MAP.get, + ), + entity_class=MatterGenericCommandSwitch, + required_attributes=( + clusters.EnergyEvse.Attributes.SupplyState, + clusters.EnergyEvse.Attributes.AcceptedCommandList, + ), + value_contains=clusters.EnergyEvse.Commands.EnableCharging.command_id, + allow_multi=True, + ), ] diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py index 7c9ca991914..cea4fe0c810 100644 --- a/homeassistant/components/matter/update.py +++ b/homeassistant/components/matter/update.py @@ -251,7 +251,7 @@ DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.UPDATE, entity_description=UpdateEntityDescription( - key="MatterUpdate", device_class=UpdateDeviceClass.FIRMWARE, name=None + key="MatterUpdate", device_class=UpdateDeviceClass.FIRMWARE ), entity_class=MatterUpdate, required_attributes=( diff --git a/homeassistant/components/matter/water_heater.py b/homeassistant/components/matter/water_heater.py new file mode 100644 index 00000000000..07c011554fa --- /dev/null +++ b/homeassistant/components/matter/water_heater.py @@ -0,0 +1,189 @@ +"""Matter water heater platform.""" + +from __future__ import annotations + +from typing import Any, cast + +from chip.clusters import Objects as clusters +from matter_server.client.models import device_types +from matter_server.common.helpers.util import create_attribute_path_from_attribute + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_HIGH_DEMAND, + STATE_OFF, + WaterHeaterEntity, + WaterHeaterEntityDescription, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_WHOLE, + Platform, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import MatterEntity +from .helpers import get_matter +from .models import MatterDiscoverySchema + +TEMPERATURE_SCALING_FACTOR = 100 + +# Map HA WH system mode to Matter ThermostatRunningMode attribute of the Thermostat cluster (Heat = 4) +WATER_HEATER_SYSTEM_MODE_MAP = { + STATE_ECO: 4, + STATE_HIGH_DEMAND: 4, + STATE_OFF: 0, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Matter WaterHeater platform from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.WATER_HEATER, async_add_entities) + + +class MatterWaterHeater(MatterEntity, WaterHeaterEntity): + """Representation of a Matter WaterHeater entity.""" + + _attr_current_temperature: float | None = None + _attr_current_operation: str + _attr_operation_list = [ + STATE_ECO, + STATE_HIGH_DEMAND, + STATE_OFF, + ] + _attr_precision = PRECISION_WHOLE + _attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE + ) + _attr_target_temperature: float | None = None + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _platform_translation_key = "water_heater" + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + target_temperature: float | None = kwargs.get(ATTR_TEMPERATURE) + if ( + target_temperature is not None + and self.target_temperature != target_temperature + ): + matter_attribute = clusters.Thermostat.Attributes.OccupiedHeatingSetpoint + await self.write_attribute( + value=round(target_temperature * TEMPERATURE_SCALING_FACTOR), + matter_attribute=matter_attribute, + ) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode.""" + self._attr_current_operation = operation_mode + # Boost 1h (3600s) + boost_info: type[ + clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct + ] = clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct( + duration=3600 + ) + system_mode_value = WATER_HEATER_SYSTEM_MODE_MAP[operation_mode] + await self.write_attribute( + value=system_mode_value, + matter_attribute=clusters.Thermostat.Attributes.SystemMode, + ) + system_mode_path = create_attribute_path_from_attribute( + endpoint_id=self._endpoint.endpoint_id, + attribute=clusters.Thermostat.Attributes.SystemMode, + ) + self._endpoint.set_attribute_value(system_mode_path, system_mode_value) + self._update_from_device() + # Trigger Boost command + if operation_mode == STATE_HIGH_DEMAND: + await self.send_device_command( + clusters.WaterHeaterManagement.Commands.Boost(boostInfo=boost_info) + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on water heater.""" + await self.async_set_operation_mode("eco") + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off water heater.""" + await self.async_set_operation_mode("off") + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + self._attr_current_temperature = self._get_temperature_in_degrees( + clusters.Thermostat.Attributes.LocalTemperature + ) + self._attr_target_temperature = self._get_temperature_in_degrees( + clusters.Thermostat.Attributes.OccupiedHeatingSetpoint + ) + boost_state = self.get_matter_attribute_value( + clusters.WaterHeaterManagement.Attributes.BoostState + ) + if boost_state == clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive: + self._attr_current_operation = STATE_HIGH_DEMAND + else: + self._attr_current_operation = STATE_ECO + self._attr_temperature = cast( + float, + self._get_temperature_in_degrees( + clusters.Thermostat.Attributes.OccupiedHeatingSetpoint + ), + ) + self._attr_min_temp = cast( + float, + self._get_temperature_in_degrees( + clusters.Thermostat.Attributes.AbsMinHeatSetpointLimit + ), + ) + self._attr_max_temp = cast( + float, + self._get_temperature_in_degrees( + clusters.Thermostat.Attributes.AbsMaxHeatSetpointLimit + ), + ) + + @callback + def _get_temperature_in_degrees( + self, attribute: type[clusters.ClusterAttributeDescriptor] + ) -> float | None: + """Return the scaled temperature value for the given attribute.""" + if (value := self.get_matter_attribute_value(attribute)) is not None: + return float(value) / TEMPERATURE_SCALING_FACTOR + return None + + +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.WATER_HEATER, + entity_description=WaterHeaterEntityDescription( + key="MatterWaterHeater", + name=None, + ), + entity_class=MatterWaterHeater, + required_attributes=( + clusters.Thermostat.Attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.Attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.Attributes.AbsMaxHeatSetpointLimit, + clusters.Thermostat.Attributes.LocalTemperature, + clusters.WaterHeaterManagement.Attributes.FeatureMap, + ), + optional_attributes=( + clusters.WaterHeaterManagement.Attributes.HeaterTypes, + clusters.WaterHeaterManagement.Attributes.BoostState, + clusters.WaterHeaterManagement.Attributes.HeatDemand, + ), + device_type=(device_types.WaterHeater,), + allow_multi=True, # also used for sensor entity + ), +] diff --git a/homeassistant/components/maytag/__init__.py b/homeassistant/components/maytag/__init__.py new file mode 100644 index 00000000000..675fae98697 --- /dev/null +++ b/homeassistant/components/maytag/__init__.py @@ -0,0 +1 @@ +"""Maytag virtual integration.""" diff --git a/homeassistant/components/maytag/manifest.json b/homeassistant/components/maytag/manifest.json new file mode 100644 index 00000000000..3cbc8f0f61a --- /dev/null +++ b/homeassistant/components/maytag/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "maytag", + "name": "Maytag", + "integration_type": "virtual", + "supported_by": "whirlpool" +} diff --git a/homeassistant/components/mcp/__init__.py b/homeassistant/components/mcp/__init__.py index 41b6a260d9f..a2a148dffd5 100644 --- a/homeassistant/components/mcp/__init__.py +++ b/homeassistant/components/mcp/__init__.py @@ -3,12 +3,15 @@ from __future__ import annotations from dataclasses import dataclass +from typing import cast +from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant -from homeassistant.helpers import llm +from homeassistant.helpers import config_entry_oauth2_flow, llm -from .const import DOMAIN -from .coordinator import ModelContextProtocolCoordinator +from .application_credentials import authorization_server_context +from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN +from .coordinator import ModelContextProtocolCoordinator, TokenManager from .types import ModelContextProtocolConfigEntry __all__ = [ @@ -20,11 +23,45 @@ __all__ = [ API_PROMPT = "The following tools are available from a remote server named {name}." +async def async_get_config_entry_implementation( + hass: HomeAssistant, entry: ModelContextProtocolConfigEntry +) -> config_entry_oauth2_flow.AbstractOAuth2Implementation | None: + """OAuth implementation for the config entry.""" + if "auth_implementation" not in entry.data: + return None + with authorization_server_context( + AuthorizationServer( + authorize_url=entry.data[CONF_AUTHORIZATION_URL], + token_url=entry.data[CONF_TOKEN_URL], + ) + ): + return await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + + +async def _create_token_manager( + hass: HomeAssistant, entry: ModelContextProtocolConfigEntry +) -> TokenManager | None: + """Create a OAuth token manager for the config entry if the server requires authentication.""" + if not (implementation := await async_get_config_entry_implementation(hass, entry)): + return None + + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + + async def token_manager() -> str: + await session.async_ensure_token_valid() + return cast(str, session.token[CONF_ACCESS_TOKEN]) + + return token_manager + + async def async_setup_entry( hass: HomeAssistant, entry: ModelContextProtocolConfigEntry ) -> bool: """Set up Model Context Protocol from a config entry.""" - coordinator = ModelContextProtocolCoordinator(hass, entry) + token_manager = await _create_token_manager(hass, entry) + coordinator = ModelContextProtocolCoordinator(hass, entry, token_manager) await coordinator.async_config_entry_first_refresh() unsub = llm.async_register_api( diff --git a/homeassistant/components/mcp/application_credentials.py b/homeassistant/components/mcp/application_credentials.py new file mode 100644 index 00000000000..9b8bed894e4 --- /dev/null +++ b/homeassistant/components/mcp/application_credentials.py @@ -0,0 +1,35 @@ +"""Application credentials platform for Model Context Protocol.""" + +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +import contextvars + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +CONF_ACTIVE_AUTHORIZATION_SERVER = "active_authorization_server" + +_mcp_context: contextvars.ContextVar[AuthorizationServer] = contextvars.ContextVar( + "mcp_authorization_server_context" +) + + +@contextmanager +def authorization_server_context( + authorization_server: AuthorizationServer, +) -> Generator[None]: + """Context manager for setting the active authorization server.""" + token = _mcp_context.set(authorization_server) + try: + yield + finally: + _mcp_context.reset(token) + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server, for the default auth implementation.""" + if _mcp_context.get() is None: + raise RuntimeError("No MCP authorization server set in context") + return _mcp_context.get() diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py index 92e0052c665..0f34962f7ee 100644 --- a/homeassistant/components/mcp/config_flow.py +++ b/homeassistant/components/mcp/config_flow.py @@ -2,20 +2,29 @@ from __future__ import annotations +from collections.abc import Mapping import logging -from typing import Any +from typing import Any, cast import httpx import voluptuous as vol +from yarl import URL -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_URL +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.config_entry_oauth2_flow import ( + AbstractOAuth2FlowHandler, + async_get_implementations, +) -from .const import DOMAIN -from .coordinator import mcp_client +from . import async_get_config_entry_implementation +from .application_credentials import authorization_server_context +from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN +from .coordinator import TokenManager, mcp_client _LOGGER = logging.getLogger(__name__) @@ -25,8 +34,62 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +# OAuth server discovery endpoint for rfc8414 +OAUTH_DISCOVERY_ENDPOINT = ".well-known/oauth-authorization-server" +MCP_DISCOVERY_HEADERS = { + "MCP-Protocol-Version": "2025-03-26", +} -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + +async def async_discover_oauth_config( + hass: HomeAssistant, mcp_server_url: str +) -> AuthorizationServer: + """Discover the OAuth configuration for the MCP server. + + This implements the functionality in the MCP spec for discovery. If the MCP server URL + is https://api.example.com/v1/mcp, then: + - The authorization base URL is https://api.example.com + - The metadata endpoint MUST be at https://api.example.com/.well-known/oauth-authorization-server + - For servers that do not implement OAuth 2.0 Authorization Server Metadata, the client uses + default paths relative to the authorization base URL. + """ + parsed_url = URL(mcp_server_url) + discovery_endpoint = str(parsed_url.with_path(OAUTH_DISCOVERY_ENDPOINT)) + try: + async with httpx.AsyncClient(headers=MCP_DISCOVERY_HEADERS) as client: + response = await client.get(discovery_endpoint) + response.raise_for_status() + except httpx.TimeoutException as error: + _LOGGER.info("Timeout connecting to MCP server: %s", error) + raise TimeoutConnectError from error + except httpx.HTTPStatusError as error: + if error.response.status_code == 404: + _LOGGER.info("Authorization Server Metadata not found, using default paths") + return AuthorizationServer( + authorize_url=str(parsed_url.with_path("/authorize")), + token_url=str(parsed_url.with_path("/token")), + ) + raise CannotConnect from error + except httpx.HTTPError as error: + _LOGGER.info("Cannot discover OAuth configuration: %s", error) + raise CannotConnect from error + + data = response.json() + authorize_url = data["authorization_endpoint"] + token_url = data["token_endpoint"] + if authorize_url.startswith("/"): + authorize_url = str(parsed_url.with_path(authorize_url)) + if token_url.startswith("/"): + token_url = str(parsed_url.with_path(token_url)) + return AuthorizationServer( + authorize_url=authorize_url, + token_url=token_url, + ) + + +async def validate_input( + hass: HomeAssistant, data: dict[str, Any], token_manager: TokenManager | None = None +) -> dict[str, Any]: """Validate the user input and connect to the MCP server.""" url = data[CONF_URL] try: @@ -34,7 +97,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, except vol.Invalid as error: raise InvalidUrl from error try: - async with mcp_client(url) as session: + async with mcp_client(url, token_manager=token_manager) as session: response = await session.initialize() except httpx.TimeoutException as error: _LOGGER.info("Timeout connecting to MCP server: %s", error) @@ -56,10 +119,17 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, return {"title": response.serverInfo.name} -class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN): +class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Handle a config flow for Model Context Protocol.""" VERSION = 1 + DOMAIN = DOMAIN + logger = _LOGGER + + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self.data: dict[str, Any] = {} async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -76,7 +146,8 @@ class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN): except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: - return self.async_abort(reason="invalid_auth") + self.data[CONF_URL] = user_input[CONF_URL] + return await self.async_step_auth_discovery() except MissingCapabilities: return self.async_abort(reason="missing_capabilities") except Exception: @@ -90,6 +161,130 @@ class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_auth_discovery( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the OAuth server discovery step. + + Since this OAuth server requires authentication, this step will attempt + to find the OAuth medata then run the OAuth authentication flow. + """ + try: + authorization_server = await async_discover_oauth_config( + self.hass, self.data[CONF_URL] + ) + except TimeoutConnectError: + return self.async_abort(reason="timeout_connect") + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + else: + _LOGGER.info("OAuth configuration: %s", authorization_server) + self.data.update( + { + CONF_AUTHORIZATION_URL: authorization_server.authorize_url, + CONF_TOKEN_URL: authorization_server.token_url, + } + ) + return await self.async_step_credentials_choice() + + def authorization_server(self) -> AuthorizationServer: + """Return the authorization server provided by the MCP server.""" + return AuthorizationServer( + self.data[CONF_AUTHORIZATION_URL], + self.data[CONF_TOKEN_URL], + ) + + async def async_step_credentials_choice( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step to ask they user if they would like to add credentials. + + This is needed since we can't automatically assume existing credentials + should be used given they may be for another existing server. + """ + with authorization_server_context(self.authorization_server()): + if not await async_get_implementations(self.hass, self.DOMAIN): + return await self.async_step_new_credentials() + return self.async_show_menu( + step_id="credentials_choice", + menu_options=["pick_implementation", "new_credentials"], + ) + + async def async_step_new_credentials( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step to take the frontend flow to enter new credentials.""" + return self.async_abort(reason="missing_credentials") + + async def async_step_pick_implementation( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the pick implementation step. + + This exists to dynamically set application credentials Authorization Server + based on the values form the OAuth discovery step. + """ + with authorization_server_context(self.authorization_server()): + return await super().async_step_pick_implementation(user_input) + + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + """Create an entry for the flow. + + Ok to override if you want to fetch extra info or even add another step. + """ + config_entry_data = { + **self.data, + **data, + } + + async def token_manager() -> str: + return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + + try: + info = await validate_input(self.hass, config_entry_data, token_manager) + except TimeoutConnectError: + return self.async_abort(reason="timeout_connect") + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except MissingCapabilities: + return self.async_abort(reason="missing_capabilities") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + # Unique id based on the application credentials OAuth Client ID + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=config_entry_data + ) + await self.async_set_unique_id(config_entry_data["auth_implementation"]) + return self.async_create_entry( + title=info["title"], + data=config_entry_data, + ) + + 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: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + config_entry = self._get_reauth_entry() + self.data = {**config_entry.data} + self.flow_impl = await async_get_config_entry_implementation( # type: ignore[assignment] + self.hass, config_entry + ) + return await self.async_step_auth() + class InvalidUrl(HomeAssistantError): """Error to indicate the URL format is invalid.""" diff --git a/homeassistant/components/mcp/const.py b/homeassistant/components/mcp/const.py index 675b2d7031c..13f63b02c73 100644 --- a/homeassistant/components/mcp/const.py +++ b/homeassistant/components/mcp/const.py @@ -1,3 +1,7 @@ """Constants for the Model Context Protocol integration.""" DOMAIN = "mcp" + +CONF_ACCESS_TOKEN = "access_token" +CONF_AUTHORIZATION_URL = "authorization_url" +CONF_TOKEN_URL = "token_url" diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py index 6e66036c548..f560875292f 100644 --- a/homeassistant/components/mcp/coordinator.py +++ b/homeassistant/components/mcp/coordinator.py @@ -1,7 +1,7 @@ """Types for the Model Context Protocol integration.""" import asyncio -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager import datetime import logging @@ -15,7 +15,7 @@ from voluptuous_openapi import convert_to_voluptuous from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers import llm from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.json import JsonObjectType @@ -27,16 +27,28 @@ _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL = datetime.timedelta(minutes=30) TIMEOUT = 10 +TokenManager = Callable[[], Awaitable[str]] + @asynccontextmanager -async def mcp_client(url: str) -> AsyncGenerator[ClientSession]: +async def mcp_client( + url: str, + token_manager: TokenManager | None = None, +) -> AsyncGenerator[ClientSession]: """Create a server-sent event MCP client. This is an asynccontext manager that exists to wrap other async context managers so that the coordinator has a single object to manage. """ + headers: dict[str, str] = {} + if token_manager is not None: + token = await token_manager() + headers["Authorization"] = f"Bearer {token}" try: - async with sse_client(url=url) as streams, ClientSession(*streams) as session: + async with ( + sse_client(url=url, headers=headers) as streams, + ClientSession(*streams) as session, + ): await session.initialize() yield session except ExceptionGroup as err: @@ -53,12 +65,14 @@ class ModelContextProtocolTool(llm.Tool): description: str | None, parameters: vol.Schema, server_url: str, + token_manager: TokenManager | None = None, ) -> None: """Initialize the tool.""" self.name = name self.description = description self.parameters = parameters self.server_url = server_url + self.token_manager = token_manager async def async_call( self, @@ -69,7 +83,7 @@ class ModelContextProtocolTool(llm.Tool): """Call the tool.""" try: async with asyncio.timeout(TIMEOUT): - async with mcp_client(self.server_url) as session: + async with mcp_client(self.server_url, self.token_manager) as session: result = await session.call_tool( tool_input.tool_name, tool_input.tool_args ) @@ -87,7 +101,12 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + token_manager: TokenManager | None = None, + ) -> None: """Initialize ModelContextProtocolCoordinator.""" super().__init__( hass, @@ -96,6 +115,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): config_entry=config_entry, update_interval=UPDATE_INTERVAL, ) + self.token_manager = token_manager async def _async_update_data(self) -> list[llm.Tool]: """Fetch data from API endpoint. @@ -105,11 +125,20 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): """ try: async with asyncio.timeout(TIMEOUT): - async with mcp_client(self.config_entry.data[CONF_URL]) as session: + async with mcp_client( + self.config_entry.data[CONF_URL], self.token_manager + ) as session: result = await session.list_tools() except TimeoutError as error: _LOGGER.debug("Timeout when listing tools: %s", error) raise UpdateFailed(f"Timeout when listing tools: {error}") from error + except httpx.HTTPStatusError as error: + _LOGGER.debug("Error communicating with API: %s", error) + if error.response.status_code == 401 and self.token_manager is not None: + raise ConfigEntryAuthFailed( + "The MCP server requires authentication" + ) from error + raise UpdateFailed(f"Error communicating with API: {error}") from error except httpx.HTTPError as err: _LOGGER.debug("Error communicating with API: %s", err) raise UpdateFailed(f"Error communicating with API: {err}") from err @@ -129,6 +158,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): tool.description, parameters, self.config_entry.data[CONF_URL], + self.token_manager, ) ) return tools diff --git a/homeassistant/components/mcp/manifest.json b/homeassistant/components/mcp/manifest.json index 9cd1e2899a6..7ff64d29aa4 100644 --- a/homeassistant/components/mcp/manifest.json +++ b/homeassistant/components/mcp/manifest.json @@ -3,6 +3,7 @@ "name": "Model Context Protocol", "codeowners": ["@allenporter"], "config_flow": true, + "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/mcp", "iot_class": "local_polling", "quality_scale": "silver", diff --git a/homeassistant/components/mcp/quality_scale.yaml b/homeassistant/components/mcp/quality_scale.yaml index 76afdf5860d..f22343c8d0e 100644 --- a/homeassistant/components/mcp/quality_scale.yaml +++ b/homeassistant/components/mcp/quality_scale.yaml @@ -44,9 +44,7 @@ rules: parallel-updates: status: exempt comment: Integration does not have platforms. - reauthentication-flow: - status: exempt - comment: Integration does not support authentication. + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/mcp/strings.json b/homeassistant/components/mcp/strings.json index 97a75fc6f85..2b59d4ffa51 100644 --- a/homeassistant/components/mcp/strings.json +++ b/homeassistant/components/mcp/strings.json @@ -8,6 +8,15 @@ "data_description": { "url": "The remote MCP server URL for the SSE endpoint, for example http://example/sse" } + }, + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "Credentials" + }, + "data_description": { + "implementation": "The credentials to use for the OAuth2 flow" + } } }, "error": { @@ -17,9 +26,15 @@ "invalid_url": "Must be a valid MCP server URL e.g. https://example.com/sse" }, "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "missing_capabilities": "The MCP server does not support a required capability (Tools)", - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "reauth_account_mismatch": "The authenticated user does not match the MCP Server user that needed re-authentication.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } } } diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index e049a827c75..a6663b089ac 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]==2025.03.26"], + "requirements": ["yt-dlp[default]==2025.03.31"], "single_config_entry": true } diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 45d08bea7ce..0979852ecce 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -68,7 +68,12 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey -from .browse_media import BrowseMedia, async_process_play_media_url # noqa: F401 +from .browse_media import ( # noqa: F401 + BrowseMedia, + SearchMedia, + SearchMediaQuery, + async_process_play_media_url, +) from .const import ( # noqa: F401 _DEPRECATED_MEDIA_CLASS_DIRECTORY, _DEPRECATED_SUPPORT_BROWSE_MEDIA, @@ -107,10 +112,12 @@ from .const import ( # noqa: F401 ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_EPISODE, ATTR_MEDIA_EXTRA, + ATTR_MEDIA_FILTER_CLASSES, ATTR_MEDIA_PLAYLIST, ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_REPEAT, + ATTR_MEDIA_SEARCH_QUERY, ATTR_MEDIA_SEASON, ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_SERIES_TITLE, @@ -128,6 +135,7 @@ from .const import ( # noqa: F401 SERVICE_CLEAR_PLAYLIST, SERVICE_JOIN, SERVICE_PLAY_MEDIA, + SERVICE_SEARCH_MEDIA, SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, SERVICE_UNJOIN, @@ -137,7 +145,7 @@ from .const import ( # noqa: F401 MediaType, RepeatMode, ) -from .errors import BrowseError +from .errors import BrowseError, SearchError _LOGGER = logging.getLogger(__name__) @@ -291,6 +299,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) websocket_api.async_register_command(hass, websocket_browse_media) + websocket_api.async_register_command(hass, websocket_search_media) hass.http.register_view(MediaPlayerImageView(component)) await component.async_setup(config) @@ -447,6 +456,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_browse_media", supports_response=SupportsResponse.ONLY, ) + component.async_register_entity_service( + SERVICE_SEARCH_MEDIA, + { + vol.Optional(ATTR_MEDIA_CONTENT_TYPE): cv.string, + vol.Optional(ATTR_MEDIA_CONTENT_ID): cv.string, + vol.Required(ATTR_MEDIA_SEARCH_QUERY): cv.string, + vol.Optional(ATTR_MEDIA_FILTER_CLASSES): vol.All( + cv.ensure_list, + [vol.In([m.value for m in MediaClass])], + lambda x: {MediaClass(item) for item in x}, + ), + }, + "async_internal_search_media", + [MediaPlayerEntityFeature.SEARCH_MEDIA], + SupportsResponse.ONLY, + ) component.async_register_entity_service( SERVICE_SHUFFLE_SET, {vol.Required(ATTR_MEDIA_SHUFFLE): cv.boolean}, @@ -1157,6 +1182,29 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ raise NotImplementedError + async def async_internal_search_media( + self, + search_query: str, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + media_filter_classes: list[MediaClass] | None = None, + ) -> SearchMedia: + return await self.async_search_media( + query=SearchMediaQuery( + search_query=search_query, + media_content_type=media_content_type, + media_content_id=media_content_id, + media_filter_classes=media_filter_classes, + ) + ) + + async def async_search_media( + self, + query: SearchMediaQuery, + ) -> SearchMedia: + """Search the media player.""" + raise NotImplementedError + def join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" raise NotImplementedError @@ -1360,6 +1408,75 @@ async def websocket_browse_media( connection.send_result(msg["id"], result) +@websocket_api.websocket_command( + { + vol.Required("type"): "media_player/search_media", + vol.Required("entity_id"): cv.entity_id, + vol.Inclusive( + ATTR_MEDIA_CONTENT_TYPE, + "media_ids", + "media_content_type and media_content_id must be provided together", + ): str, + vol.Inclusive( + ATTR_MEDIA_CONTENT_ID, + "media_ids", + "media_content_type and media_content_id must be provided together", + ): str, + vol.Required(ATTR_MEDIA_SEARCH_QUERY): str, + vol.Optional(ATTR_MEDIA_FILTER_CLASSES): vol.All( + cv.ensure_list, + [vol.In([m.value for m in MediaClass])], + lambda x: {MediaClass(item) for item in x}, + ), + } +) +@websocket_api.async_response +async def websocket_search_media( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Search media available to the media_player entity. + + To use, media_player integrations can implement + MediaPlayerEntity.async_search_media() + """ + player = hass.data[DATA_COMPONENT].get_entity(msg["entity_id"]) + + if player is None: + connection.send_error(msg["id"], "entity_not_found", "Entity not found") + return + + if MediaPlayerEntityFeature.SEARCH_MEDIA not in player.supported_features_compat: + connection.send_message( + websocket_api.error_message( + msg["id"], ERR_NOT_SUPPORTED, "Player does not support searching media" + ) + ) + return + + media_content_type = msg.get(ATTR_MEDIA_CONTENT_TYPE) + media_content_id = msg.get(ATTR_MEDIA_CONTENT_ID) + query = str(msg.get(ATTR_MEDIA_SEARCH_QUERY)) + media_filter_classes = msg.get(ATTR_MEDIA_FILTER_CLASSES, []) + + try: + payload = await player.async_internal_search_media( + query, + media_content_type, + media_content_id, + media_filter_classes, + ) + except SearchError as err: + connection.send_message( + websocket_api.error_message(msg["id"], ERR_UNKNOWN_ERROR, str(err)) + ) + return + + result = payload.as_dict() + connection.send_result(msg["id"], result) + + _FETCH_TIMEOUT = aiohttp.ClientTimeout(total=10) diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py index d234050c1b2..ec9d70476a3 100644 --- a/homeassistant/components/media_player/browse_media.py +++ b/homeassistant/components/media_player/browse_media.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Sequence +from dataclasses import dataclass, field from datetime import timedelta import logging from typing import Any @@ -109,6 +110,7 @@ class BrowseMedia: children_media_class: MediaClass | str | None = None, thumbnail: str | None = None, not_shown: int = 0, + can_search: bool = False, ) -> None: """Initialize browse media item.""" self.media_class = media_class @@ -121,6 +123,7 @@ class BrowseMedia: self.children_media_class = children_media_class self.thumbnail = thumbnail self.not_shown = not_shown + self.can_search = can_search def as_dict(self, *, parent: bool = True) -> dict[str, Any]: """Convert Media class to browse media dictionary.""" @@ -135,6 +138,7 @@ class BrowseMedia: "children_media_class": self.children_media_class, "can_play": self.can_play, "can_expand": self.can_expand, + "can_search": self.can_search, "thumbnail": self.thumbnail, } @@ -163,3 +167,27 @@ class BrowseMedia: def __repr__(self) -> str: """Return representation of browse media.""" return f"" + + +@dataclass(kw_only=True, frozen=True) +class SearchMedia: + """Represent search results.""" + + version: int = field(default=1) + result: list[BrowseMedia] + + def as_dict(self, *, parent: bool = True) -> dict[str, Any]: + """Convert SearchMedia class to browse media dictionary.""" + return { + "result": [item.as_dict(parent=parent) for item in self.result], + } + + +@dataclass(kw_only=True, frozen=True) +class SearchMediaQuery: + """Represent a search media file.""" + + search_query: str + media_content_type: MediaType | str | None = field(default=None) + media_content_id: str | None = None + media_filter_classes: list[MediaClass] | None = field(default=None) diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index 387fdb05401..8d85d7cd106 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -26,6 +26,8 @@ ATTR_MEDIA_ARTIST = "media_artist" ATTR_MEDIA_CHANNEL = "media_channel" ATTR_MEDIA_CONTENT_ID = "media_content_id" ATTR_MEDIA_CONTENT_TYPE = "media_content_type" +ATTR_MEDIA_SEARCH_QUERY = "search_query" +ATTR_MEDIA_FILTER_CLASSES = "media_filter_classes" ATTR_MEDIA_DURATION = "media_duration" ATTR_MEDIA_ENQUEUE = "enqueue" ATTR_MEDIA_EXTRA = "extra" @@ -174,6 +176,7 @@ SERVICE_CLEAR_PLAYLIST = "clear_playlist" SERVICE_JOIN = "join" SERVICE_PLAY_MEDIA = "play_media" SERVICE_BROWSE_MEDIA = "browse_media" +SERVICE_SEARCH_MEDIA = "search_media" SERVICE_SELECT_SOUND_MODE = "select_sound_mode" SERVICE_SELECT_SOURCE = "select_source" SERVICE_UNJOIN = "unjoin" @@ -220,6 +223,7 @@ class MediaPlayerEntityFeature(IntFlag): GROUPING = 524288 MEDIA_ANNOUNCE = 1048576 MEDIA_ENQUEUE = 2097152 + SEARCH_MEDIA = 4194304 # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. diff --git a/homeassistant/components/media_player/errors.py b/homeassistant/components/media_player/errors.py index 5888ba6b5b0..23db94a330e 100644 --- a/homeassistant/components/media_player/errors.py +++ b/homeassistant/components/media_player/errors.py @@ -9,3 +9,7 @@ class MediaPlayerException(HomeAssistantError): class BrowseError(MediaPlayerException): """Error while browsing.""" + + +class SearchError(MediaPlayerException): + """Error while searching.""" diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json index 5008ea62d2e..fb45a821062 100644 --- a/homeassistant/components/media_player/icons.json +++ b/homeassistant/components/media_player/icons.json @@ -68,6 +68,9 @@ "repeat_set": { "service": "mdi:repeat" }, + "search_media": { + "service": "mdi:text-search" + }, "select_sound_mode": { "service": "mdi:surround-sound" }, diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py index af37c0d68bb..4349362b13a 100644 --- a/homeassistant/components/media_player/intent.py +++ b/homeassistant/components/media_player/intent.py @@ -93,7 +93,6 @@ async def async_setup_intents(hass: HomeAssistant) -> None: DOMAIN, SERVICE_VOLUME_SET, required_domains={DOMAIN}, - required_states={MediaPlayerState.PLAYING}, required_features=MediaPlayerEntityFeature.VOLUME_SET, required_slots={ ATTR_MEDIA_VOLUME_LEVEL: intent.IntentSlotInfo( @@ -159,7 +158,6 @@ class MediaUnpauseHandler(intent.ServiceIntentHandler): DOMAIN, SERVICE_MEDIA_PLAY, required_domains={DOMAIN}, - required_states={MediaPlayerState.PAUSED}, description="Resumes a media player", platforms={DOMAIN}, device_classes={MediaPlayerDeviceClass}, diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 6b13a6b9c09..ac359de1a5b 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -169,6 +169,8 @@ browse_media: target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.BROWSE_MEDIA fields: media_content_type: required: false @@ -181,6 +183,35 @@ browse_media: selector: text: +search_media: + target: + entity: + domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.SEARCH_MEDIA + fields: + search_query: + required: true + example: "Beatles" + selector: + text: + media_content_type: + required: false + example: "music" + selector: + text: + media_content_id: + required: false + example: "A:ALBUMARTIST/Beatles" + selector: + text: + media_filter_classes: + required: false + example: ["album", "artist"] + selector: + text: + multiple: true + select_source: target: entity: diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 87b5ec692af..617cb258af7 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -274,6 +274,28 @@ } } }, + "search_media": { + "name": "Search media", + "description": "Searches the available media.", + "fields": { + "media_content_id": { + "name": "[%key:component::media_player::services::browse_media::fields::media_content_id::name%]", + "description": "[%key:component::media_player::services::browse_media::fields::media_content_id::description%]" + }, + "media_content_type": { + "name": "[%key:component::media_player::services::browse_media::fields::media_content_type::name%]", + "description": "[%key:component::media_player::services::browse_media::fields::media_content_type::description%]" + }, + "search_query": { + "name": "Search query", + "description": "The term to search for." + }, + "media_filter_classes": { + "name": "Media class filter", + "description": "List of media classes to filter the search results by." + } + } + }, "select_source": { "name": "Select source", "description": "Sends the media player the command to change input source.", @@ -344,7 +366,7 @@ }, "repeat": { "options": { - "off": "Off", + "off": "[%key:common::state::off%]", "all": "Repeat all", "one": "Repeat one" } diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 5c6165a3477..e1e9a4feb4b 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -33,7 +33,7 @@ from .const import ( URI_SCHEME, URI_SCHEME_REGEX, ) -from .error import MediaSourceError, Unresolvable +from .error import MediaSourceError, UnknownMediaSource, Unresolvable from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia __all__ = [ @@ -113,7 +113,11 @@ def _get_media_item( return MediaSourceItem(hass, domain, "", target_media_player) if item.domain is not None and item.domain not in hass.data[DOMAIN]: - raise ValueError("Unknown media source") + raise UnknownMediaSource( + translation_domain=DOMAIN, + translation_key="unknown_media_source", + translation_placeholders={"domain": item.domain}, + ) return item @@ -132,7 +136,14 @@ async def async_browse_media( try: item = await _get_media_item(hass, media_content_id, None).async_browse() except ValueError as err: - raise BrowseError(str(err)) from err + raise BrowseError( + translation_domain=DOMAIN, + translation_key="browse_media_failed", + translation_placeholders={ + "media_content_id": str(media_content_id), + "error": str(err), + }, + ) from err if content_filter is None or item.children is None: return item @@ -165,7 +176,14 @@ async def async_resolve_media( try: item = _get_media_item(hass, media_content_id, target_media_player) except ValueError as err: - raise Unresolvable(str(err)) from err + raise Unresolvable( + translation_domain=DOMAIN, + translation_key="resolve_media_failed", + translation_placeholders={ + "media_content_id": str(media_content_id), + "error": str(err), + }, + ) from err return await item.async_resolve() diff --git a/homeassistant/components/media_source/error.py b/homeassistant/components/media_source/error.py index 120e7583e23..66e8842e08a 100644 --- a/homeassistant/components/media_source/error.py +++ b/homeassistant/components/media_source/error.py @@ -9,3 +9,7 @@ class MediaSourceError(HomeAssistantError): class Unresolvable(MediaSourceError): """When media ID is not resolvable.""" + + +class UnknownMediaSource(MediaSourceError, ValueError): + """When media source is unknown.""" diff --git a/homeassistant/components/media_source/strings.json b/homeassistant/components/media_source/strings.json new file mode 100644 index 00000000000..40204fc32db --- /dev/null +++ b/homeassistant/components/media_source/strings.json @@ -0,0 +1,13 @@ +{ + "exceptions": { + "browse_media_failed": { + "message": "Failed to browse media with content id {media_content_id}: {error}" + }, + "resolve_media_failed": { + "message": "Failed to resolve media with content id {media_content_id}: {error}" + }, + "unknown_media_source": { + "message": "Unknown media source: {domain}" + } + } +} diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 682a28ea080..19c333e5825 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -57,8 +57,8 @@ ATA_HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in ATA_HVAC_MODE_LOOKUP.items()} ATW_ZONE_HVAC_MODE_LOOKUP = { - atw.ZONE_OPERATION_MODE_HEAT: HVACMode.HEAT, - atw.ZONE_OPERATION_MODE_COOL: HVACMode.COOL, + atw.ZONE_STATUS_HEAT: HVACMode.HEAT, + atw.ZONE_STATUS_COOL: HVACMode.COOL, } ATW_ZONE_HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in ATW_ZONE_HVAC_MODE_LOOKUP.items()} diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index f61ed412be1..a9440ad8300 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/melcloud", "iot_class": "cloud_polling", "loggers": ["pymelcloud"], - "requirements": ["pymelcloud==2.5.9"] + "requirements": ["python-melcloud==0.1.0"] } diff --git a/homeassistant/components/met/coordinator.py b/homeassistant/components/met/coordinator.py index de27da7a07f..8b6243d9daf 100644 --- a/homeassistant/components/met/coordinator.py +++ b/homeassistant/components/met/coordinator.py @@ -2,11 +2,10 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import timedelta import logging from random import randrange -from types import MappingProxyType from typing import Any, Self import metno @@ -41,7 +40,7 @@ class CannotConnect(HomeAssistantError): class MetWeatherData: """Keep data for Met.no weather entities.""" - def __init__(self, hass: HomeAssistant, config: MappingProxyType[str, Any]) -> None: + def __init__(self, hass: HomeAssistant, config: Mapping[str, Any]) -> None: """Initialise the weather entity data.""" self.hass = hass self._config = config diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index c4f9c8e6885..8d8317607be 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -2,7 +2,7 @@ from __future__ import annotations -from types import MappingProxyType +from collections.abc import Mapping from typing import TYPE_CHECKING, Any from homeassistant.components.weather import ( @@ -82,7 +82,7 @@ async def async_setup_entry( async_add_entities(entities) -def _calculate_unique_id(config: MappingProxyType[str, Any], hourly: bool) -> str: +def _calculate_unique_id(config: Mapping[str, Any], hourly: bool) -> str: """Calculate unique ID.""" name_appendix = "" if hourly: diff --git a/homeassistant/components/met_eireann/__init__.py b/homeassistant/components/met_eireann/__init__.py index 01917707bf7..62d7d21134c 100644 --- a/homeassistant/components/met_eireann/__init__.py +++ b/homeassistant/components/met_eireann/__init__.py @@ -1,8 +1,8 @@ """The met_eireann component.""" +from collections.abc import Mapping from datetime import timedelta import logging -from types import MappingProxyType from typing import Any, Self import meteireann @@ -74,7 +74,7 @@ class MetEireannWeatherData: """Keep data for Met Éireann weather entities.""" def __init__( - self, config: MappingProxyType[str, Any], weather_data: meteireann.WeatherData + self, config: Mapping[str, Any], weather_data: meteireann.WeatherData ) -> None: """Initialise the weather entity data.""" self._config = config diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index 72706ccb70f..97bbd952740 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -1,7 +1,7 @@ """Support for Met Éireann weather service.""" +from collections.abc import Mapping import logging -from types import MappingProxyType from typing import Any, cast from homeassistant.components.weather import ( @@ -64,7 +64,7 @@ async def async_setup_entry( async_add_entities([MetEireannWeather(coordinator, config_entry.data)]) -def _calculate_unique_id(config: MappingProxyType[str, Any], hourly: bool) -> str: +def _calculate_unique_id(config: Mapping[str, Any], hourly: bool) -> str: """Calculate unique ID.""" name_appendix = "" if hourly: @@ -90,7 +90,7 @@ class MetEireannWeather( def __init__( self, coordinator: DataUpdateCoordinator[MetEireannWeatherData], - config: MappingProxyType[str, Any], + config: Mapping[str, Any], ) -> None: """Initialise the platform with a data instance and site.""" super().__init__(coordinator) diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 67a56271c2b..e2df35f21f3 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -161,6 +161,11 @@ class MeteoFranceWeather( """Return the wind speed.""" return self.coordinator.data.current_forecast["wind"]["speed"] + @property + def native_wind_gust_speed(self): + """Return the wind gust speed.""" + return self.coordinator.data.current_forecast["wind"].get("gust") + @property def wind_bearing(self): """Return the wind bearing.""" diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py new file mode 100644 index 00000000000..98a6919980a --- /dev/null +++ b/homeassistant/components/miele/__init__.py @@ -0,0 +1,89 @@ +"""The Miele integration.""" + +from __future__ import annotations + +from aiohttp import ClientError, ClientResponseError + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) + +from .api import AsyncConfigEntryAuth +from .const import DOMAIN +from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator + +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CLIMATE, + Platform.FAN, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bool: + """Set up Miele from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + + session = OAuth2Session(hass, entry, implementation) + auth = AsyncConfigEntryAuth(async_get_clientsession(hass), session) + try: + await auth.async_get_access_token() + except ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="config_entry_auth_failed", + ) from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="config_entry_not_ready", + ) from err + except ClientError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="config_entry_not_ready", + ) from err + + # Setup MieleAPI and coordinator for data fetch + coordinator = MieleDataUpdateCoordinator(hass, auth) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + entry.async_create_background_task( + hass, + coordinator.api.listen_events( + data_callback=coordinator.callback_update_data, + actions_callback=coordinator.callback_update_actions, + ), + "pymiele event listener", + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bool: + """Unload a config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: MieleConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + and identifier[1] in config_entry.runtime_data.data.devices + ) diff --git a/homeassistant/components/miele/api.py b/homeassistant/components/miele/api.py new file mode 100644 index 00000000000..632314f405c --- /dev/null +++ b/homeassistant/components/miele/api.py @@ -0,0 +1,27 @@ +"""API for Miele bound to Home Assistant OAuth.""" + +from typing import cast + +from aiohttp import ClientSession +from pymiele import MIELE_API, AbstractAuth + +from homeassistant.helpers import config_entry_oauth2_flow + + +class AsyncConfigEntryAuth(AbstractAuth): + """Provide Miele authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Miele auth.""" + super().__init__(websession, MIELE_API) + 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 cast(str, self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/miele/application_credentials.py b/homeassistant/components/miele/application_credentials.py new file mode 100644 index 00000000000..d40ef765ce0 --- /dev/null +++ b/homeassistant/components/miele/application_credentials.py @@ -0,0 +1,21 @@ +"""Application credentials platform for the Miele integration.""" + +from pymiele import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "register_url": "https://www.miele.com/f/com/en/register_api.aspx", + } diff --git a/homeassistant/components/miele/binary_sensor.py b/homeassistant/components/miele/binary_sensor.py new file mode 100644 index 00000000000..5eb9eccc5df --- /dev/null +++ b/homeassistant/components/miele/binary_sensor.py @@ -0,0 +1,283 @@ +"""Binary sensor platform for Miele integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Final, cast + +from pymiele import MieleDevice + +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 AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import MieleAppliance +from .coordinator import MieleConfigEntry +from .entity import MieleEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class MieleBinarySensorDescription(BinarySensorEntityDescription): + """Class describing Miele binary sensor entities.""" + + value_fn: Callable[[MieleDevice], StateType] + + +@dataclass +class MieleBinarySensorDefinition: + """Class for defining binary sensor entities.""" + + types: tuple[MieleAppliance, ...] + description: MieleBinarySensorDescription + + +BINARY_SENSOR_TYPES: Final[tuple[MieleBinarySensorDefinition, ...]] = ( + MieleBinarySensorDefinition( + types=( + MieleAppliance.DISH_WARMER, + MieleAppliance.DISHWASHER, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.FRIDGE, + MieleAppliance.MICROWAVE, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.OVEN, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.STEAM_OVEN, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + ), + description=MieleBinarySensorDescription( + key="state_signal_door", + value_fn=lambda value: value.state_signal_door, + device_class=BinarySensorDeviceClass.DOOR, + ), + ), + MieleBinarySensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.HOOD, + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.DISH_WARMER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleBinarySensorDescription( + key="state_signal_info", + value_fn=lambda value: value.state_signal_info, + device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="notification_active", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleBinarySensorDefinition( + types=( + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.DISH_WARMER, + MieleAppliance.DISHWASHER, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.FRIDGE, + MieleAppliance.HOB_HIGHLIGHT, + MieleAppliance.HOB_INDUCT_EXTR, + MieleAppliance.HOB_INDUCTION, + MieleAppliance.HOOD, + MieleAppliance.MICROWAVE, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.OVEN, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.STEAM_OVEN, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + ), + description=MieleBinarySensorDescription( + key="state_signal_failure", + value_fn=lambda value: value.state_signal_failure, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleBinarySensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.DISH_WARMER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.HOOD, + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.HOB_INDUCT_EXTR, + ), + description=MieleBinarySensorDescription( + key="state_full_remote_control", + translation_key="remote_control", + value_fn=lambda value: value.state_full_remote_control, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleBinarySensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.HOOD, + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.HOB_INDUCT_EXTR, + ), + description=MieleBinarySensorDescription( + key="state_smart_grid", + value_fn=lambda value: value.state_smart_grid, + translation_key="smart_grid", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleBinarySensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.DISH_WARMER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.HOOD, + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.HOB_INDUCT_EXTR, + ), + description=MieleBinarySensorDescription( + key="state_mobile_start", + value_fn=lambda value: value.state_mobile_start, + translation_key="mobile_start", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the binary sensor platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + MieleBinarySensor(coordinator, device_id, definition.description) + for device_id, device in coordinator.data.devices.items() + for definition in BINARY_SENSOR_TYPES + if device.device_type in definition.types + ) + + +class MieleBinarySensor(MieleEntity, BinarySensorEntity): + """Representation of a Binary Sensor.""" + + entity_description: MieleBinarySensorDescription + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return cast(bool, self.entity_description.value_fn(self.device)) diff --git a/homeassistant/components/miele/button.py b/homeassistant/components/miele/button.py new file mode 100644 index 00000000000..70d4489e9be --- /dev/null +++ b/homeassistant/components/miele/button.py @@ -0,0 +1,152 @@ +"""Platform for Miele button integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import Final + +import aiohttp + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN, PROCESS_ACTION, MieleActions, MieleAppliance +from .coordinator import MieleConfigEntry +from .entity import MieleEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class MieleButtonDescription(ButtonEntityDescription): + """Class describing Miele button entities.""" + + press_data: MieleActions + + +@dataclass +class MieleButtonDefinition: + """Class for defining button entities.""" + + types: tuple[MieleAppliance, ...] + description: MieleButtonDescription + + +BUTTON_TYPES: Final[tuple[MieleButtonDefinition, ...]] = ( + MieleButtonDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.DIALOG_OVEN, + ), + description=MieleButtonDescription( + key="start", + translation_key="start", + press_data=MieleActions.START, + entity_registry_enabled_default=False, + ), + ), + MieleButtonDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.HOOD, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.DIALOG_OVEN, + ), + description=MieleButtonDescription( + key="stop", + translation_key="stop", + press_data=MieleActions.STOP, + entity_registry_enabled_default=False, + ), + ), + MieleButtonDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.WASHER_DRYER, + ), + description=MieleButtonDescription( + key="pause", + translation_key="pause", + press_data=MieleActions.PAUSE, + entity_registry_enabled_default=False, + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the button platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + MieleButton(coordinator, device_id, definition.description) + for device_id, device in coordinator.data.devices.items() + for definition in BUTTON_TYPES + if device.device_type in definition.types + ) + + +class MieleButton(MieleEntity, ButtonEntity): + """Representation of a Button.""" + + entity_description: MieleButtonDescription + + @property + def available(self) -> bool: + """Return the availability of the entity.""" + + return ( + super().available + and self.entity_description.press_data in self.action.process_actions + ) + + async def async_press(self) -> None: + """Press the button.""" + _LOGGER.debug("Press: %s", self.entity_description.key) + try: + await self.api.send_action( + self._device_id, + {PROCESS_ACTION: self.entity_description.press_data}, + ) + except aiohttp.ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from ex diff --git a/homeassistant/components/miele/climate.py b/homeassistant/components/miele/climate.py new file mode 100644 index 00000000000..22257448e3a --- /dev/null +++ b/homeassistant/components/miele/climate.py @@ -0,0 +1,235 @@ +"""Platform for Miele integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any, Final, cast + +import aiohttp +from pymiele import MieleDevice + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityDescription, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DEVICE_TYPE_TAGS, DISABLED_TEMP_ENTITIES, DOMAIN, MieleAppliance +from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator +from .entity import MieleEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class MieleClimateDescription(ClimateEntityDescription): + """Class describing Miele climate entities.""" + + value_fn: Callable[[MieleDevice], StateType] + target_fn: Callable[[MieleDevice], StateType] + zone: int = 1 + + +@dataclass +class MieleClimateDefinition: + """Class for defining climate entities.""" + + types: tuple[MieleAppliance, ...] + description: MieleClimateDescription + + +CLIMATE_TYPES: Final[tuple[MieleClimateDefinition, ...]] = ( + MieleClimateDefinition( + types=( + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.WINE_CABINET_FREEZER, + ), + description=MieleClimateDescription( + key="thermostat", + value_fn=( + lambda value: cast(int, value.state_temperatures[0].temperature) / 100.0 + ), + target_fn=( + lambda value: cast(int, value.state_target_temperature[0].temperature) + / 100.0 + ), + zone=1, + ), + ), + MieleClimateDefinition( + types=( + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.WINE_CABINET_FREEZER, + ), + description=MieleClimateDescription( + key="thermostat2", + value_fn=( + lambda value: cast(int, value.state_temperatures[1].temperature) / 100.0 + ), + target_fn=( + lambda value: cast(int, value.state_target_temperature[1].temperature) + / 100.0 + ), + translation_key="zone_2", + zone=2, + ), + ), + MieleClimateDefinition( + types=( + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.WINE_CABINET_FREEZER, + ), + description=MieleClimateDescription( + key="thermostat3", + value_fn=( + lambda value: cast(int, value.state_temperatures[2].temperature) / 100.0 + ), + target_fn=( + lambda value: cast(int, value.state_target_temperature[2].temperature) + / 100.0 + ), + translation_key="zone_3", + zone=3, + ), + ), +) + +ZONE1_DEVICES = { + MieleAppliance.FRIDGE: DEVICE_TYPE_TAGS[MieleAppliance.FRIDGE], + MieleAppliance.FRIDGE_FREEZER: DEVICE_TYPE_TAGS[MieleAppliance.FRIDGE], + MieleAppliance.FREEZER: DEVICE_TYPE_TAGS[MieleAppliance.FREEZER], +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the climate platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + MieleClimate(coordinator, device_id, definition.description) + for device_id, device in coordinator.data.devices.items() + for definition in CLIMATE_TYPES + if ( + device.device_type in definition.types + and (definition.description.value_fn(device) not in DISABLED_TEMP_ENTITIES) + ) + ) + + +class MieleClimate(MieleEntity, ClimateEntity): + """Representation of a climate entity.""" + + entity_description: MieleClimateDescription + _attr_precision = PRECISION_WHOLE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature_step = 1.0 + _attr_hvac_modes = [HVACMode.COOL] + _attr_hvac_mode = HVACMode.COOL + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return cast(float, self.entity_description.value_fn(self.device)) + + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: MieleClimateDescription, + ) -> None: + """Initialize the climate entity.""" + super().__init__(coordinator, device_id, description) + + t_key = self.entity_description.translation_key + + if description.zone == 1: + t_key = ZONE1_DEVICES.get( + cast(MieleAppliance, self.device.device_type), "zone_1" + ) + if self.device.device_type in ( + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + ): + self._attr_name = None + + if description.zone == 2: + if self.device.device_type in ( + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.WINE_CABINET_FREEZER, + ): + t_key = DEVICE_TYPE_TAGS[MieleAppliance.FREEZER] + else: + t_key = "zone_2" + elif description.zone == 3: + t_key = "zone_3" + + self._attr_translation_key = t_key + self._attr_unique_id = f"{device_id}-{description.key}-{description.zone}" + + @property + def target_temperature(self) -> float | None: + """Return the target temperature.""" + + return cast(float | None, self.entity_description.target_fn(self.device)) + + @property + def max_temp(self) -> float: + """Return the maximum target temperature.""" + return cast( + float, + self.action.target_temperature[self.entity_description.zone - 1].max, + ) + + @property + def min_temp(self) -> float: + """Return the minimum target temperature.""" + return cast( + float, + self.action.target_temperature[self.entity_description.zone - 1].min, + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + return + try: + await self.api.set_target_temperature( + self._device_id, temperature, self.entity_description.zone + ) + except aiohttp.ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from err + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/miele/config_flow.py b/homeassistant/components/miele/config_flow.py new file mode 100644 index 00000000000..d3c7dbba12b --- /dev/null +++ b/homeassistant/components/miele/config_flow.py @@ -0,0 +1,73 @@ +"""Config flow for Miele.""" + +from collections.abc import Mapping +import logging +from typing import Any + +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlowResult, +) +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Miele OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + # "vg" is mandatory but the value doesn't seem to matter + return { + "vg": "sv-SE", + } + + 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: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + ) + + return await self.async_step_user() + + async def async_step_reconfigure( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """User initiated reconfiguration.""" + return await self.async_step_user() + + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + """Create or update the config entry.""" + + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=data + ) + + if self.source == SOURCE_RECONFIGURE: + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), data=data + ) + return await super().async_oauth_create_entry(data) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py new file mode 100644 index 00000000000..85934afae09 --- /dev/null +++ b/homeassistant/components/miele/const.py @@ -0,0 +1,1095 @@ +"""Constants for the Miele integration.""" + +from enum import IntEnum + +DOMAIN = "miele" +MANUFACTURER = "Miele" + +ACTIONS = "actions" +POWER_ON = "powerOn" +POWER_OFF = "powerOff" +PROCESS_ACTION = "processAction" +VENTILATION_STEP = "ventilationStep" +TARGET_TEMPERATURE = "targetTemperature" +AMBIENT_LIGHT = "ambientLight" +LIGHT = "light" +LIGHT_ON = 1 +LIGHT_OFF = 2 + +DISABLED_TEMP_ENTITIES = ( + -32768 / 100, + -32766 / 100, +) + + +class MieleAppliance(IntEnum): + """Define appliance types.""" + + WASHING_MACHINE = 1 + TUMBLE_DRYER = 2 + WASHING_MACHINE_SEMI_PROFESSIONAL = 3 + TUMBLE_DRYER_SEMI_PROFESSIONAL = 4 + WASHING_MACHINE_PROFESSIONAL = 5 + DRYER_PROFESSIONAL = 6 + DISHWASHER = 7 + DISHWASHER_SEMI_PROFESSIONAL = 8 + DISHWASHER_PROFESSIONAL = 9 + OVEN = 12 + OVEN_MICROWAVE = 13 + HOB_HIGHLIGHT = 14 + STEAM_OVEN = 15 + MICROWAVE = 16 + COFFEE_SYSTEM = 17 + HOOD = 18 + FRIDGE = 19 + FREEZER = 20 + FRIDGE_FREEZER = 21 + ROBOT_VACUUM_CLEANER = 23 + WASHER_DRYER = 24 + DISH_WARMER = 25 + HOB_INDUCTION = 27 + STEAM_OVEN_COMBI = 31 + WINE_CABINET = 32 + WINE_CONDITIONING_UNIT = 33 + WINE_STORAGE_CONDITIONING_UNIT = 34 + STEAM_OVEN_MICRO = 45 + DIALOG_OVEN = 67 + WINE_CABINET_FREEZER = 68 + STEAM_OVEN_MK2 = 73 + HOB_INDUCT_EXTR = 74 + + +DEVICE_TYPE_TAGS = { + MieleAppliance.WASHING_MACHINE: "washing_machine", + MieleAppliance.TUMBLE_DRYER: "tumble_dryer", + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL: "washing_machine", + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL: "tumble_dryer", + MieleAppliance.WASHING_MACHINE_PROFESSIONAL: "washing_machine", + MieleAppliance.DRYER_PROFESSIONAL: "tumble_dryer", + MieleAppliance.DISHWASHER: "dishwasher", + MieleAppliance.DISHWASHER_SEMI_PROFESSIONAL: "dishwasher", + MieleAppliance.DISHWASHER_PROFESSIONAL: "dishwasher", + MieleAppliance.OVEN: "oven", + MieleAppliance.OVEN_MICROWAVE: "oven_microwave", + MieleAppliance.HOB_HIGHLIGHT: "hob", + MieleAppliance.STEAM_OVEN: "steam_oven", + MieleAppliance.MICROWAVE: "microwave", + MieleAppliance.COFFEE_SYSTEM: "coffee_system", + MieleAppliance.HOOD: "hood", + MieleAppliance.FRIDGE: "refrigerator", + MieleAppliance.FREEZER: "freezer", + MieleAppliance.FRIDGE_FREEZER: "fridge_freezer", + MieleAppliance.ROBOT_VACUUM_CLEANER: "robot_vacuum_cleaner", + MieleAppliance.WASHER_DRYER: "washer_dryer", + MieleAppliance.DISH_WARMER: "warming_drawer", + MieleAppliance.HOB_INDUCTION: "hob", + MieleAppliance.STEAM_OVEN_COMBI: "steam_oven_combi", + MieleAppliance.WINE_CABINET: "wine_cabinet", + MieleAppliance.WINE_CONDITIONING_UNIT: "wine_conditioning_unit", + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT: "wine_unit", + MieleAppliance.STEAM_OVEN_MICRO: "steam_oven_micro", + MieleAppliance.DIALOG_OVEN: "dialog_oven", + MieleAppliance.WINE_CABINET_FREEZER: "wine_cabinet_freezer", + MieleAppliance.STEAM_OVEN_MK2: "steam_oven", + MieleAppliance.HOB_INDUCT_EXTR: "hob_extraction", +} + + +class StateStatus(IntEnum): + """Define appliance states.""" + + RESERVED = 0 + OFF = 1 + ON = 2 + PROGRAMMED = 3 + WAITING_TO_START = 4 + IN_USE = 5 + PAUSE = 6 + PROGRAM_ENDED = 7 + FAILURE = 8 + PROGRAM_INTERRUPTED = 9 + IDLE = 10 + RINSE_HOLD = 11 + SERVICE = 12 + SUPERFREEZING = 13 + SUPERCOOLING = 14 + SUPERHEATING = 15 + SUPERCOOLING_SUPERFREEZING = 146 + AUTOCLEANING = 147 + NOT_CONNECTED = 255 + + +STATE_STATUS_TAGS = { + StateStatus.OFF: "off", + StateStatus.ON: "on", + StateStatus.PROGRAMMED: "programmed", + StateStatus.WAITING_TO_START: "waiting_to_start", + StateStatus.IN_USE: "in_use", + StateStatus.PAUSE: "pause", + StateStatus.PROGRAM_ENDED: "program_ended", + StateStatus.FAILURE: "failure", + StateStatus.PROGRAM_INTERRUPTED: "program_interrupted", + StateStatus.IDLE: "idle", + StateStatus.RINSE_HOLD: "rinse_hold", + StateStatus.SERVICE: "service", + StateStatus.SUPERFREEZING: "superfreezing", + StateStatus.SUPERCOOLING: "supercooling", + StateStatus.SUPERHEATING: "superheating", + StateStatus.SUPERCOOLING_SUPERFREEZING: "supercooling_superfreezing", + StateStatus.AUTOCLEANING: "autocleaning", + StateStatus.NOT_CONNECTED: "not_connected", +} + + +class MieleActions(IntEnum): + """Define appliance actions.""" + + START = 1 + STOP = 2 + PAUSE = 3 + START_SUPERFREEZE = 4 + STOP_SUPERFREEZE = 5 + START_SUPERCOOL = 6 + STOP_SUPERCOOL = 7 + + +# Possible actions +PROCESS_ACTIONS = { + "start": MieleActions.START, + "stop": MieleActions.STOP, + "pause": MieleActions.PAUSE, + "start_superfreezing": MieleActions.START_SUPERFREEZE, + "stop_superfreezing": MieleActions.STOP_SUPERFREEZE, + "start_supercooling": MieleActions.START_SUPERCOOL, + "stop_supercooling": MieleActions.STOP_SUPERCOOL, +} + +STATE_PROGRAM_PHASE_WASHING_MACHINE = { + 0: "not_running", # Returned by the API when the machine is switched off entirely. + 256: "not_running", + 257: "pre_wash", + 258: "soak", + 259: "pre_wash", + 260: "main_wash", + 261: "rinse", + 262: "rinse_hold", + 263: "cleaning", + 264: "cooling_down", + 265: "drain", + 266: "spin", + 267: "anti_crease", + 268: "finished", + 269: "venting", + 270: "starch_stop", + 271: "freshen_up_and_moisten", + 272: "steam_smoothing", + 279: "hygiene", + 280: "drying", + 285: "disinfecting", + 295: "steam_smoothing", + 65535: "not_running", # Seems to be default for some devices. +} + +STATE_PROGRAM_PHASE_TUMBLE_DRYER = { + 0: "not_running", + 512: "not_running", + 513: "program_running", + 514: "drying", + 515: "machine_iron", + 516: "hand_iron_2", + 517: "normal", + 518: "normal_plus", + 519: "cooling_down", + 520: "hand_iron_1", + 521: "anti_crease", + 522: "finished", + 523: "extra_dry", + 524: "hand_iron", + 526: "moisten", + 527: "thermo_spin", + 528: "timed_drying", + 529: "warm_air", + 530: "steam_smoothing", + 531: "comfort_cooling", + 532: "rinse_out_lint", + 533: "rinses", + 535: "not_running", + 534: "smoothing", + 536: "not_running", + 537: "not_running", + 538: "slightly_dry", + 539: "safety_cooling", + 65535: "not_running", +} + +STATE_PROGRAM_PHASE_DISHWASHER = { + 1792: "not_running", + 1793: "reactivating", + 1794: "pre_dishwash", + 1795: "main_dishwash", + 1796: "rinse", + 1797: "interim_rinse", + 1798: "final_rinse", + 1799: "drying", + 1800: "finished", + 1801: "pre_dishwash", + 65535: "not_running", +} + +STATE_PROGRAM_PHASE_OVEN = { + 0: "not_running", + 3073: "heating_up", + 3074: "process_running", + 3078: "process_finished", + 3084: "energy_save", + 65535: "not_running", +} +STATE_PROGRAM_PHASE_WARMING_DRAWER = { + 0: "not_running", + 3075: "door_open", + 3094: "keeping_warm", + 3088: "cooling_down", + 65535: "not_running", +} +STATE_PROGRAM_PHASE_MICROWAVE = { + 0: "not_running", + 3329: "heating", + 3330: "process_running", + 3334: "process_finished", + 3340: "energy_save", + 65535: "not_running", +} +STATE_PROGRAM_PHASE_COFFEE_SYSTEM = { + # Coffee system + 3073: "heating_up", + 4352: "not_running", + 4353: "espresso", + 4355: "milk_foam", + 4361: "dispensing", + 4369: "pre_brewing", + 4377: "grinding", + 4401: "2nd_grinding", + 4354: "hot_milk", + 4393: "2nd_pre_brewing", + 4385: "2nd_espresso", + 4404: "dispensing", + 4405: "rinse", + 65535: "not_running", +} +STATE_PROGRAM_PHASE_ROBOT_VACUUM_CLEANER = { + 0: "not_running", + 5889: "vacuum_cleaning", + 5890: "returning", + 5891: "vacuum_cleaning_paused", + 5892: "going_to_target_area", + 5893: "wheel_lifted", # F1 + 5894: "dirty_sensors", # F2 + 5895: "dust_box_missing", # F3 + 5896: "blocked_drive_wheels", # F4 + 5897: "blocked_brushes", # F5 + 5898: "motor_overload", # F6 + 5899: "internal_fault", # F7 + 5900: "blocked_front_wheel", # F8 + 5903: "docked", + 5904: "docked", + 5910: "remote_controlled", + 65535: "not_running", +} +STATE_PROGRAM_PHASE_MICROWAVE_OVEN_COMBO = { + 0: "not_running", + 3863: "steam_reduction", + 7938: "process_running", + 7939: "waiting_for_start", + 7940: "heating_up_phase", + 7942: "process_finished", + 65535: "not_running", +} + +STATE_PROGRAM_PHASE: dict[int, dict[int, str]] = { + MieleAppliance.WASHING_MACHINE: STATE_PROGRAM_PHASE_WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL: STATE_PROGRAM_PHASE_WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_PROFESSIONAL: STATE_PROGRAM_PHASE_WASHING_MACHINE, + MieleAppliance.TUMBLE_DRYER: STATE_PROGRAM_PHASE_TUMBLE_DRYER, + MieleAppliance.DRYER_PROFESSIONAL: STATE_PROGRAM_PHASE_TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL: STATE_PROGRAM_PHASE_TUMBLE_DRYER, + MieleAppliance.DISHWASHER: STATE_PROGRAM_PHASE_DISHWASHER, + MieleAppliance.DISHWASHER_SEMI_PROFESSIONAL: STATE_PROGRAM_PHASE_DISHWASHER, + MieleAppliance.DISHWASHER_PROFESSIONAL: STATE_PROGRAM_PHASE_DISHWASHER, + MieleAppliance.OVEN: STATE_PROGRAM_PHASE_OVEN, + MieleAppliance.OVEN_MICROWAVE: STATE_PROGRAM_PHASE_MICROWAVE_OVEN_COMBO, + MieleAppliance.STEAM_OVEN: STATE_PROGRAM_PHASE_OVEN, + MieleAppliance.DIALOG_OVEN: STATE_PROGRAM_PHASE_OVEN, + MieleAppliance.MICROWAVE: STATE_PROGRAM_PHASE_MICROWAVE, + MieleAppliance.COFFEE_SYSTEM: STATE_PROGRAM_PHASE_COFFEE_SYSTEM, + MieleAppliance.ROBOT_VACUUM_CLEANER: STATE_PROGRAM_PHASE_ROBOT_VACUUM_CLEANER, +} + +STATE_PROGRAM_TYPE = { + 0: "normal_operation_mode", + 1: "own_program", + 2: "automatic_program", + 3: "cleaning_care_program", + 4: "maintenance_program", +} + +WASHING_MACHINE_PROGRAM_ID: dict[int, str] = { + -1: "no_program", # Extrapolated from other device types. + 0: "no_program", # Returned by the API when no program is selected. + 1: "cottons", + 3: "minimum_iron", + 4: "delicates", + 8: "woollens", + 9: "silks", + 17: "starch", + 18: "rinse", + 21: "drain_spin", + 22: "curtains", + 23: "shirts", + 24: "denim", + 27: "proofing", + 29: "sportswear", + 31: "automatic_plus", + 37: "outerwear", + 39: "pillows", + 45: "cool_air", # washer-dryer + 46: "warm_air", # washer-dryer + 48: "rinse_out_lint", # washer-dryer + 50: "dark_garments", + 52: "separate_rinse_starch", + 53: "first_wash", + 69: "cottons_hygiene", + 75: "steam_care", # washer-dryer + 76: "freshen_up", # washer-dryer + 77: "trainers", + 91: "clean_machine", + 95: "down_duvets", + 122: "express_20", + 123: "denim", + 129: "down_filled_items", + 133: "cottons_eco", + 146: "quick_power_wash", + 190: "eco_40_60", +} + +DISHWASHER_PROGRAM_ID: dict[int, str] = { + -1: "no_program", # Sometimes returned by the API when the machine is switched off entirely, in conjunection with program phase 65535. + 0: "no_program", # Returned by the API when the machine is switched off entirely. + 1: "intensive", + 2: "maintenance", + 3: "eco", + 6: "automatic", + 7: "automatic", + 9: "solar_save", + 10: "gentle", + 11: "extra_quiet", + 12: "hygiene", + 13: "quick_power_wash", + 14: "pasta_paela", + 17: "tall_items", + 19: "glasses_warm", + 26: "intensive", + 27: "maintenance", # or maintenance_program? + 28: "eco", + 30: "normal", + 31: "automatic", + 32: "automatic", # sources disagree on ID + 34: "solar_save", + 35: "gentle", + 36: "extra_quiet", + 37: "hygiene", + 38: "quick_power_wash", + 42: "tall_items", + 44: "power_wash", +} +TUMBLE_DRYER_PROGRAM_ID: dict[int, str] = { + -1: "no_program", # Extrapolated from other device types. + 0: "no_program", # Extrapolated from other device types + 10: "automatic_plus", + 20: "cottons", + 23: "cottons_hygiene", + 30: "minimum_iron", + 31: "gentle_minimum_iron", + 40: "woollens_handcare", + 50: "delicates", + 60: "warm_air", + 70: "cool_air", + 80: "express", + 90: "cottons", + 100: "gentle_smoothing", + 120: "proofing", + 130: "denim", + 131: "gentle_denim", + 150: "sportswear", + 160: "outerwear", + 170: "silks_handcare", + 190: "standard_pillows", + 220: "basket_program", + 240: "smoothing", + 99001: "steam_smoothing", + 99002: "bed_linen", + 99003: "cottons_eco", + 99004: "shirts", + 99005: "large_pillows", +} + +OVEN_PROGRAM_ID: dict[int, str] = { + -1: "no_program", # Extrapolated from other device types. + 0: "no_program", # Extrapolated from other device types + 1: "defrost", + 6: "eco_fan_heat", + 7: "auto_roast", + 10: "full_grill", + 11: "economy_grill", + 13: "fan_plus", + 14: "intensive_bake", + 19: "microwave", + 24: "conventional_heat", + 25: "top_heat", + 29: "fan_grill", + 31: "bottom_heat", + 35: "moisture_plus_auto_roast", + 40: "moisture_plus_fan_plus", + 74: "moisture_plus_intensive_bake", + 76: "moisture_plus_conventional_heat", + 49: "moisture_plus_fan_plus", + 356: "defrost", + 357: "drying", + 358: "heat_crockery", + 361: "steam_cooking", + 362: "keeping_warm", + 512: "1_tray", + 513: "2_trays", + 529: "baking_tray", + 621: "prove_15_min", + 622: "prove_30_min", + 623: "prove_45_min", + 99001: "steam_bake", + 17003: "no_program", +} +DISH_WARMER_PROGRAM_ID: dict[int, str] = { + -1: "no_program", + 0: "no_program", + 1: "warm_cups_glasses", + 2: "warm_dishes_plates", + 3: "keep_warm", + 4: "slow_roasting", +} +ROBOT_VACUUM_CLEANER_PROGRAM_ID: dict[int, str] = { + -1: "no_program", # Extrapolated from other device types + 0: "no_program", # Extrapolated from other device types + 1: "auto", + 2: "spot", + 3: "turbo", + 4: "silent", +} +COFFEE_SYSTEM_PROGRAM_ID: dict[int, str] = { + -1: "no_program", # Extrapolated from other device types + 0: "no_program", # Extrapolated from other device types + 16016: "appliance_settings", # display brightness + 16018: "appliance_settings", # volume + 16019: "appliance_settings", # buttons volume + 16020: "appliance_settings", # child lock + 16021: "appliance_settings", # water hardness + 16027: "appliance_settings", # welcome sound + 16033: "appliance_settings", # connection status + 16035: "appliance_settings", # remote control + 16037: "appliance_settings", # remote update + 17004: "check_appliance", + # profile 1 + 24000: "ristretto", + 24001: "espresso", + 24002: "coffee", + 24003: "long_coffee", + 24004: "cappuccino", + 24005: "cappuccino_italiano", + 24006: "latte_macchiato", + 24007: "espresso_macchiato", + 24008: "cafe_au_lait", + 24009: "caffe_latte", + 24012: "flat_white", + 24013: "very_hot_water", + 24014: "hot_water", + 24015: "hot_milk", + 24016: "milk_foam", + 24017: "black_tea", + 24018: "herbal_tea", + 24019: "fruit_tea", + 24020: "green_tea", + 24021: "white_tea", + 24022: "japanese_tea", + # profile 2 + 24032: "ristretto", + 24033: "espresso", + 24034: "coffee", + 24035: "long_coffee", + 24036: "cappuccino", + 24037: "cappuccino_italiano", + 24038: "latte_macchiato", + 24039: "espresso_macchiato", + 24040: "cafe_au_lait", + 24041: "caffe_latte", + 24044: "flat_white", + 24045: "very_hot_water", + 24046: "hot_water", + 24047: "hot_milk", + 24048: "milk_foam", + 24049: "black_tea", + 24050: "herbal_tea", + 24051: "fruit_tea", + 24052: "green_tea", + 24053: "white_tea", + 24054: "japanese_tea", + # profile 3 + 24064: "ristretto", + 24065: "espresso", + 24066: "coffee", + 24067: "long_coffee", + 24068: "cappuccino", + 24069: "cappuccino_italiano", + 24070: "latte_macchiato", + 24071: "espresso_macchiato", + 24072: "cafe_au_lait", + 24073: "caffe_latte", + 24076: "flat_white", + 24077: "very_hot_water", + 24078: "hot_water", + 24079: "hot_milk", + 24080: "milk_foam", + 24081: "black_tea", + 24082: "herbal_tea", + 24083: "fruit_tea", + 24084: "green_tea", + 24085: "white_tea", + 24086: "japanese_tea", + # profile 4 + 24096: "ristretto", + 24097: "espresso", + 24098: "coffee", + 24099: "long_coffee", + 24100: "cappuccino", + 24101: "cappuccino_italiano", + 24102: "latte_macchiato", + 24103: "espresso_macchiato", + 24104: "cafe_au_lait", + 24105: "caffe_latte", + 24108: "flat_white", + 24109: "very_hot_water", + 24110: "hot_water", + 24111: "hot_milk", + 24112: "milk_foam", + 24113: "black_tea", + 24114: "herbal_tea", + 24115: "fruit_tea", + 24116: "green_tea", + 24117: "white_tea", + 24118: "japanese_tea", + # profile 5 + 24128: "ristretto", + 24129: "espresso", + 24130: "coffee", + 24131: "long_coffee", + 24132: "cappuccino", + 24133: "cappuccino_italiano", + 24134: "latte_macchiato", + 24135: "espresso_macchiato", + 24136: "cafe_au_lait", + 24137: "caffe_latte", + 24140: "flat_white", + 24141: "very_hot_water", + 24142: "hot_water", + 24143: "hot_milk", + 24144: "milk_foam", + 24145: "black_tea", + 24146: "herbal_tea", + 24147: "fruit_tea", + 24148: "green_tea", + 24149: "white_tea", + 24150: "japanese_tea", + # special programs + 24400: "coffee_pot", + 24407: "barista_assistant", + # machine settings menu + 24500: "appliance_settings", # total dispensed + 24502: "appliance_settings", # lights appliance on + 24503: "appliance_settings", # lights appliance off + 24504: "appliance_settings", # turn off lights after + 24506: "appliance_settings", # altitude + 24513: "appliance_settings", # performance mode + 24516: "appliance_settings", # turn off after + 24537: "appliance_settings", # advanced mode + 24542: "appliance_settings", # tea timer + 24549: "appliance_settings", # total coffee dispensed + 24550: "appliance_settings", # total tea dispensed + 24551: "appliance_settings", # total ristretto + 24552: "appliance_settings", # total cappuccino + 24553: "appliance_settings", # total espresso + 24554: "appliance_settings", # total coffee + 24555: "appliance_settings", # total long coffee + 24556: "appliance_settings", # total italian cappuccino + 24557: "appliance_settings", # total latte macchiato + 24558: "appliance_settings", # total caffe latte + 24560: "appliance_settings", # total espresso macchiato + 24562: "appliance_settings", # total flat white + 24563: "appliance_settings", # total coffee with milk + 24564: "appliance_settings", # total black tea + 24565: "appliance_settings", # total herbal tea + 24566: "appliance_settings", # total fruit tea + 24567: "appliance_settings", # total green tea + 24568: "appliance_settings", # total white tea + 24569: "appliance_settings", # total japanese tea + 24571: "appliance_settings", # total milk foam + 24572: "appliance_settings", # total hot milk + 24573: "appliance_settings", # total hot water + 24574: "appliance_settings", # total very hot water + 24575: "appliance_settings", # counter to descaling + 24576: "appliance_settings", # counter to brewing unit degreasing + # maintenance + 24750: "appliance_rinse", + 24751: "descaling", + 24753: "brewing_unit_degrease", + 24754: "milk_pipework_rinse", + 24759: "appliance_rinse", + 24773: "appliance_rinse", + 24787: "appliance_rinse", + 24788: "appliance_rinse", + 24789: "milk_pipework_clean", + # profiles settings menu + 24800: "appliance_settings", # add profile + 24801: "appliance_settings", # ask profile settings + 24813: "appliance_settings", # modify profile name +} + +STEAM_OVEN_MICRO_PROGRAM_ID: dict[int, str] = { + 8: "steam_cooking", + 19: "microwave", + 53: "popcorn", + 54: "quick_mw", + 72: "sous_vide", + 75: "eco_steam_cooking", + 77: "rapid_steam_cooking", + 326: "descale", + 330: "menu_cooking", + 2018: "reheating_with_steam", + 2019: "defrosting_with_steam", + 2020: "blanching", + 2021: "bottling", + 2022: "heat_crockery", + 2023: "prove_dough", + 2027: "soak", + 2029: "reheating_with_microwave", + 2030: "defrosting_with_microwave", + 2031: "artichokes_small", + 2032: "artichokes_medium", + 2033: "artichokes_large", + 2034: "eggplant_sliced", + 2035: "eggplant_diced", + 2036: "cauliflower_whole_small", + 2039: "cauliflower_whole_medium", + 2042: "cauliflower_whole_large", + 2046: "cauliflower_florets_small", + 2048: "cauliflower_florets_medium", + 2049: "cauliflower_florets_large", + 2051: "green_beans_whole", + 2052: "green_beans_cut", + 2053: "yellow_beans_whole", + 2054: "yellow_beans_cut", + 2055: "broad_beans", + 2056: "common_beans", + 2057: "runner_beans_whole", + 2058: "runner_beans_pieces", + 2059: "runner_beans_sliced", + 2060: "broccoli_whole_small", + 2061: "broccoli_whole_medium", + 2062: "broccoli_whole_large", + 2064: "broccoli_florets_small", + 2066: "broccoli_florets_medium", + 2068: "broccoli_florets_large", + 2069: "endive_halved", + 2070: "endive_quartered", + 2071: "endive_strips", + 2072: "chinese_cabbage_cut", + 2073: "peas", + 2074: "fennel_halved", + 2075: "fennel_quartered", + 2076: "fennel_strips", + 2077: "kale_cut", + 2080: "potatoes_in_the_skin_waxy_small_steam_cooking", + 2081: "potatoes_in_the_skin_waxy_small_rapid_steam_cooking", + 2083: "potatoes_in_the_skin_waxy_medium_steam_cooking", + 2084: "potatoes_in_the_skin_waxy_medium_rapid_steam_cooking", + 2086: "potatoes_in_the_skin_waxy_large_steam_cooking", + 2087: "potatoes_in_the_skin_waxy_large_rapid_steam_cooking", + 2088: "potatoes_in_the_skin_floury_small", + 2091: "potatoes_in_the_skin_floury_medium", + 2094: "potatoes_in_the_skin_floury_large", + 2097: "potatoes_in_the_skin_mainly_waxy_small", + 2100: "potatoes_in_the_skin_mainly_waxy_medium", + 2103: "potatoes_in_the_skin_mainly_waxy_large", + 2106: "potatoes_waxy_whole_small", + 2109: "potatoes_waxy_whole_medium", + 2112: "potatoes_waxy_whole_large", + 2115: "potatoes_waxy_halved", + 2116: "potatoes_waxy_quartered", + 2117: "potatoes_waxy_diced", + 2118: "potatoes_mainly_waxy_small", + 2119: "potatoes_mainly_waxy_medium", + 2120: "potatoes_mainly_waxy_large", + 2121: "potatoes_mainly_waxy_halved", + 2122: "potatoes_mainly_waxy_quartered", + 2123: "potatoes_mainly_waxy_diced", + 2124: "potatoes_floury_whole_small", + 2125: "potatoes_floury_whole_medium", + 2126: "potatoes_floury_whole_large", + 2127: "potatoes_floury_halved", + 2128: "potatoes_floury_quartered", + 2129: "potatoes_floury_diced", + 2130: "german_turnip_sliced", + 2131: "german_turnip_cut_into_batons", + 2132: "german_turnip_sliced", + 2133: "pumpkin_diced", + 2134: "corn_on_the_cob", + 2135: "mangel_cut", + 2136: "bunched_carrots_whole_small", + 2137: "bunched_carrots_whole_medium", + 2138: "bunched_carrots_whole_large", + 2139: "bunched_carrots_halved", + 2140: "bunched_carrots_quartered", + 2141: "bunched_carrots_diced", + 2142: "bunched_carrots_cut_into_batons", + 2143: "bunched_carrots_sliced", + 2144: "parisian_carrots_small", + 2145: "parisian_carrots_medium", + 2146: "parisian_carrots_large", + 2147: "carrots_whole_small", + 2148: "carrots_whole_medium", + 2149: "carrots_whole_large", + 2150: "carrots_halved", + 2151: "carrots_quartered", + 2152: "carrots_diced", + 2153: "carrots_cut_into_batons", + 2155: "carrots_sliced", + 2156: "pepper_halved", + 2157: "pepper_quartered", + 2158: "pepper_strips", + 2159: "pepper_diced", + 2160: "parsnip_sliced", + 2161: "parsnip_diced", + 2162: "parsnip_cut_into_batons", + 2163: "parsley_root_sliced", + 2164: "parsley_root_diced", + 2165: "parsley_root_cut_into_batons", + 2166: "leek_pieces", + 2167: "leek_rings", + 2168: "romanesco_whole_small", + 2169: "romanesco_whole_medium", + 2170: "romanesco_whole_large", + 2171: "romanesco_florets_small", + 2172: "romanesco_florets_medium", + 2173: "romanesco_florets_large", + 2175: "brussels_sprout", + 2176: "beetroot_whole_small", + 2177: "beetroot_whole_medium", + 2178: "beetroot_whole_large", + 2179: "red_cabbage_cut", + 2180: "black_salsify_thin", + 2181: "black_salsify_medium", + 2182: "black_salsify_thick", + 2183: "celery_pieces", + 2184: "celery_sliced", + 2185: "celeriac_sliced", + 2186: "celeriac_cut_into_batons", + 2187: "celeriac_diced", + 2188: "white_asparagus_thin", + 2189: "white_asparagus_medium", + 2190: "white_asparagus_thick", + 2192: "green_asparagus_thin", + 2194: "green_asparagus_medium", + 2196: "green_asparagus_thick", + 2197: "spinach", + 2198: "pointed_cabbage_cut", + 2199: "yam_halved", + 2200: "yam_quartered", + 2201: "yam_strips", + 2202: "swede_diced", + 2203: "swede_cut_into_batons", + 2204: "teltow_turnip_sliced", + 2205: "teltow_turnip_diced", + 2206: "jerusalem_artichoke_sliced", + 2207: "jerusalem_artichoke_diced", + 2208: "green_cabbage_cut", + 2209: "savoy_cabbage_cut", + 2210: "courgette_sliced", + 2211: "courgette_diced", + 2212: "snow_pea", + 2214: "perch_whole", + 2215: "perch_fillet_2_cm", + 2216: "perch_fillet_3_cm", + 2217: "gilt_head_bream_whole", + 2220: "gilt_head_bream_fillet", + 2221: "codfish_piece", + 2222: "codfish_fillet", + 2224: "trout", + 2225: "pike_fillet", + 2226: "pike_piece", + 2227: "halibut_fillet_2_cm", + 2230: "halibut_fillet_3_cm", + 2231: "codfish_fillet", + 2232: "codfish_piece", + 2233: "carp", + 2234: "salmon_fillet_2_cm", + 2235: "salmon_fillet_3_cm", + 2238: "salmon_steak_2_cm", + 2239: "salmon_steak_3_cm", + 2240: "salmon_piece", + 2241: "salmon_trout", + 2244: "iridescent_shark_fillet", + 2245: "red_snapper_fillet_2_cm", + 2248: "red_snapper_fillet_3_cm", + 2249: "redfish_fillet_2_cm", + 2250: "redfish_fillet_3_cm", + 2251: "redfish_piece", + 2252: "char", + 2253: "plaice_whole_2_cm", + 2254: "plaice_whole_3_cm", + 2255: "plaice_whole_4_cm", + 2256: "plaice_fillet_1_cm", + 2259: "plaice_fillet_2_cm", + 2260: "coalfish_fillet_2_cm", + 2261: "coalfish_fillet_3_cm", + 2262: "coalfish_piece", + 2263: "sea_devil_fillet_3_cm", + 2266: "sea_devil_fillet_4_cm", + 2267: "common_sole_fillet_1_cm", + 2270: "common_sole_fillet_2_cm", + 2271: "atlantic_catfish_fillet_1_cm", + 2272: "atlantic_catfish_fillet_2_cm", + 2273: "turbot_fillet_2_cm", + 2276: "turbot_fillet_3_cm", + 2277: "tuna_steak", + 2278: "tuna_fillet_2_cm", + 2279: "tuna_fillet_3_cm", + 2280: "tilapia_fillet_1_cm", + 2281: "tilapia_fillet_2_cm", + 2282: "nile_perch_fillet_2_cm", + 2283: "nile_perch_fillet_3_cm", + 2285: "zander_fillet", + 2288: "soup_hen", + 2291: "poularde_whole", + 2292: "poularde_breast", + 2294: "turkey_breast", + 2302: "chicken_tikka_masala_with_rice", + 2312: "veal_fillet_whole", + 2313: "veal_fillet_medaillons_1_cm", + 2315: "veal_fillet_medaillons_2_cm", + 2317: "veal_fillet_medaillons_3_cm", + 2324: "goulash_soup", + 2327: "dutch_hash", + 2328: "stuffed_cabbage", + 2330: "beef_tenderloin", + 2333: "beef_tenderloin_medaillons_1_cm_steam_cooking", + 2334: "beef_tenderloin_medaillons_2_cm_steam_cooking", + 2335: "beef_tenderloin_medaillons_3_cm_steam_cooking", + 2339: "silverside_5_cm", + 2342: "silverside_7_5_cm", + 2345: "silverside_10_cm", + 2348: "meat_for_soup_back_or_top_rib", + 2349: "meat_for_soup_leg_steak", + 2350: "meat_for_soup_brisket", + 2353: "viennese_silverside", + 2354: "whole_ham_steam_cooking", + 2355: "whole_ham_reheating", + 2359: "kasseler_piece", + 2361: "kasseler_slice", + 2363: "knuckle_of_pork_fresh", + 2364: "knuckle_of_pork_cured", + 2367: "pork_tenderloin_medaillons_3_cm", + 2368: "pork_tenderloin_medaillons_4_cm", + 2369: "pork_tenderloin_medaillons_5_cm", + 2429: "pumpkin_soup", + 2430: "meat_with_rice", + 2431: "beef_casserole", + 2450: "risotto", + 2451: "risotto", + 2453: "rice_pudding_steam_cooking", + 2454: "rice_pudding_rapid_steam_cooking", + 2461: "amaranth", + 2462: "bulgur", + 2463: "spelt_whole", + 2464: "spelt_cracked", + 2465: "green_spelt_whole", + 2466: "green_spelt_cracked", + 2467: "oats_whole", + 2468: "oats_cracked", + 2469: "millet", + 2470: "quinoa", + 2471: "polenta_swiss_style_fine_polenta", + 2472: "polenta_swiss_style_medium_polenta", + 2473: "polenta_swiss_style_coarse_polenta", + 2474: "polenta", + 2475: "rye_whole", + 2476: "rye_cracked", + 2477: "wheat_whole", + 2478: "wheat_cracked", + 2480: "gnocchi_fresh", + 2481: "yeast_dumplings_fresh", + 2482: "potato_dumplings_raw_boil_in_bag", + 2483: "potato_dumplings_raw_deep_frozen", + 2484: "potato_dumplings_half_half_boil_in_bag", + 2485: "potato_dumplings_half_half_deep_frozen", + 2486: "bread_dumplings_boil_in_the_bag", + 2487: "bread_dumplings_fresh", + 2488: "ravioli_fresh", + 2489: "spaetzle_fresh", + 2490: "tagliatelli_fresh", + 2491: "schupfnudeln_potato_noodels", + 2492: "tortellini_fresh", + 2493: "red_lentils", + 2494: "brown_lentils", + 2495: "beluga_lentils", + 2496: "green_split_peas", + 2497: "yellow_split_peas", + 2498: "chick_peas", + 2499: "white_beans", + 2500: "pinto_beans", + 2501: "red_beans", + 2502: "black_beans", + 2503: "hens_eggs_size_s_soft", + 2504: "hens_eggs_size_s_medium", + 2505: "hens_eggs_size_s_hard", + 2506: "hens_eggs_size_m_soft", + 2507: "hens_eggs_size_m_medium", + 2508: "hens_eggs_size_m_hard", + 2509: "hens_eggs_size_l_soft", + 2510: "hens_eggs_size_l_medium", + 2511: "hens_eggs_size_l_hard", + 2512: "hens_eggs_size_xl_soft", + 2513: "hens_eggs_size_xl_medium", + 2514: "hens_eggs_size_xl_hard", + 2515: "swiss_toffee_cream_100_ml", + 2516: "swiss_toffee_cream_150_ml", + 2518: "toffee_date_dessert_several_small", + 2520: "cheesecake_several_small", + 2521: "cheesecake_one_large", + 2522: "christmas_pudding_cooking", + 2523: "christmas_pudding_heating", + 2524: "treacle_sponge_pudding_several_small", + 2525: "treacle_sponge_pudding_one_large", + 2526: "sweet_cheese_dumplings", + 2527: "apples_whole", + 2528: "apples_halved", + 2529: "apples_quartered", + 2530: "apples_sliced", + 2531: "apples_diced", + 2532: "apricots_halved_steam_cooking", + 2533: "apricots_halved_skinning", + 2534: "apricots_quartered", + 2535: "apricots_wedges", + 2536: "pears_halved", + 2537: "pears_quartered", + 2538: "pears_wedges", + 2539: "sweet_cherries", + 2540: "sour_cherries", + 2541: "pears_to_cook_small_whole", + 2542: "pears_to_cook_small_halved", + 2543: "pears_to_cook_small_quartered", + 2544: "pears_to_cook_medium_whole", + 2545: "pears_to_cook_medium_halved", + 2546: "pears_to_cook_medium_quartered", + 2547: "pears_to_cook_large_whole", + 2548: "pears_to_cook_large_halved", + 2549: "pears_to_cook_large_quartered", + 2550: "mirabelles", + 2551: "nectarines_peaches_halved_steam_cooking", + 2552: "nectarines_peaches_halved_skinning", + 2553: "nectarines_peaches_quartered", + 2554: "nectarines_peaches_wedges", + 2555: "plums_whole", + 2556: "plums_halved", + 2557: "cranberries", + 2558: "quinces_diced", + 2559: "greenage_plums", + 2560: "rhubarb_chunks", + 2561: "gooseberries", + 2562: "mushrooms_whole", + 2563: "mushrooms_halved", + 2564: "mushrooms_sliced", + 2565: "mushrooms_quartered", + 2566: "mushrooms_diced", + 2567: "cep", + 2568: "chanterelle", + 2569: "oyster_mushroom_whole", + 2570: "oyster_mushroom_strips", + 2571: "oyster_mushroom_diced", + 2572: "saucisson", + 2573: "bruehwurst_sausages", + 2574: "bologna_sausage", + 2575: "veal_sausages", + 2577: "crevettes", + 2579: "prawns", + 2581: "king_prawns", + 2583: "small_shrimps", + 2585: "large_shrimps", + 2587: "mussels", + 2589: "scallops", + 2591: "venus_clams", + 2592: "goose_barnacles", + 2593: "cockles", + 2594: "razor_clams_small", + 2595: "razor_clams_medium", + 2596: "razor_clams_large", + 2597: "mussels_in_sauce", + 2598: "bottling_soft", + 2599: "bottling_medium", + 2600: "bottling_hard", + 2601: "melt_chocolate", + 2602: "dissolve_gelatine", + 2603: "sweat_onions", + 2604: "cook_bacon", + 2605: "heating_damp_flannels", + 2606: "decrystallise_honey", + 2607: "make_yoghurt", + 2687: "toffee_date_dessert_one_large", + 2694: "beef_tenderloin_medaillons_1_cm_low_temperature_cooking", + 2695: "beef_tenderloin_medaillons_2_cm_low_temperature_cooking", + 2696: "beef_tenderloin_medaillons_3_cm_low_temperature_cooking", + 3373: "wild_rice", + 3376: "wholegrain_rice", + 3380: "parboiled_rice_steam_cooking", + 3381: "parboiled_rice_rapid_steam_cooking", + 3383: "basmati_rice_steam_cooking", + 3384: "basmati_rice_rapid_steam_cooking", + 3386: "jasmine_rice_steam_cooking", + 3387: "jasmine_rice_rapid_steam_cooking", + 3389: "huanghuanian_steam_cooking", + 3390: "huanghuanian_rapid_steam_cooking", + 3392: "simiao_steam_cooking", + 3393: "simiao_rapid_steam_cooking", + 3395: "long_grain_rice_general_steam_cooking", + 3396: "long_grain_rice_general_rapid_steam_cooking", + 3398: "chongming_steam_cooking", + 3399: "chongming_rapid_steam_cooking", + 3401: "wuchang_steam_cooking", + 3402: "wuchang_rapid_steam_cooking", + 3404: "uonumma_koshihikari_steam_cooking", + 3405: "uonumma_koshihikari_rapid_steam_cooking", + 3407: "sheyang_steam_cooking", + 3408: "sheyang_rapid_steam_cooking", + 3410: "round_grain_rice_general_steam_cooking", + 3411: "round_grain_rice_general_rapid_steam_cooking", +} + +STATE_PROGRAM_ID: dict[int, dict[int, str]] = { + MieleAppliance.WASHING_MACHINE: WASHING_MACHINE_PROGRAM_ID, + MieleAppliance.TUMBLE_DRYER: TUMBLE_DRYER_PROGRAM_ID, + MieleAppliance.DISHWASHER: DISHWASHER_PROGRAM_ID, + MieleAppliance.DISH_WARMER: DISH_WARMER_PROGRAM_ID, + MieleAppliance.OVEN: OVEN_PROGRAM_ID, + MieleAppliance.OVEN_MICROWAVE: OVEN_PROGRAM_ID, + MieleAppliance.STEAM_OVEN_MK2: OVEN_PROGRAM_ID, + MieleAppliance.STEAM_OVEN: OVEN_PROGRAM_ID, + MieleAppliance.STEAM_OVEN_COMBI: OVEN_PROGRAM_ID, + MieleAppliance.STEAM_OVEN_MICRO: STEAM_OVEN_MICRO_PROGRAM_ID, + MieleAppliance.WASHER_DRYER: WASHING_MACHINE_PROGRAM_ID, + MieleAppliance.ROBOT_VACUUM_CLEANER: ROBOT_VACUUM_CLEANER_PROGRAM_ID, + MieleAppliance.COFFEE_SYSTEM: COFFEE_SYSTEM_PROGRAM_ID, +} diff --git a/homeassistant/components/miele/coordinator.py b/homeassistant/components/miele/coordinator.py new file mode 100644 index 00000000000..8902f0f173a --- /dev/null +++ b/homeassistant/components/miele/coordinator.py @@ -0,0 +1,87 @@ +"""Coordinator module for Miele integration.""" + +from __future__ import annotations + +import asyncio.timeouts +from dataclasses import dataclass +from datetime import timedelta +import logging + +from pymiele import MieleAction, MieleDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .api import AsyncConfigEntryAuth +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +type MieleConfigEntry = ConfigEntry[MieleDataUpdateCoordinator] + + +@dataclass +class MieleCoordinatorData: + """Data class for storing coordinator data.""" + + devices: dict[str, MieleDevice] + actions: dict[str, MieleAction] + + +class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]): + """Coordinator for Miele data.""" + + def __init__( + self, + hass: HomeAssistant, + api: AsyncConfigEntryAuth, + ) -> None: + """Initialize the Miele data coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=120), + ) + self.api = api + + async def _async_update_data(self) -> MieleCoordinatorData: + """Fetch data from the Miele API.""" + async with asyncio.timeout(10): + # Get devices + devices_json = await self.api.get_devices() + devices = { + device_id: MieleDevice(device) + for device_id, device in devices_json.items() + } + actions = {} + for device_id in devices: + actions_json = await self.api.get_actions(device_id) + actions[device_id] = MieleAction(actions_json) + return MieleCoordinatorData(devices=devices, actions=actions) + + async def callback_update_data(self, devices_json: dict[str, dict]) -> None: + """Handle data update from the API.""" + devices = { + device_id: MieleDevice(device) for device_id, device in devices_json.items() + } + self.async_set_updated_data( + MieleCoordinatorData( + devices=devices, + actions=self.data.actions, + ) + ) + + async def callback_update_actions(self, actions_json: dict[str, dict]) -> None: + """Handle data update from the API.""" + actions = { + device_id: MieleAction(action) for device_id, action in actions_json.items() + } + self.async_set_updated_data( + MieleCoordinatorData( + devices=self.data.devices, + actions=actions, + ) + ) diff --git a/homeassistant/components/miele/diagnostics.py b/homeassistant/components/miele/diagnostics.py new file mode 100644 index 00000000000..eb0a1fe49c3 --- /dev/null +++ b/homeassistant/components/miele/diagnostics.py @@ -0,0 +1,90 @@ +"""Diagnostics support for Miele.""" + +from __future__ import annotations + +import hashlib +from typing import Any, cast + +from pymiele import completed_warnings + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from .coordinator import MieleConfigEntry + +TO_REDACT = {"access_token", "refresh_token", "fabNumber"} + + +def hash_identifier(key: str) -> str: + """Hash the identifier string.""" + return f"**REDACTED_{hashlib.sha256(key.encode()).hexdigest()[:16]}" + + +def redact_identifiers(in_data: dict[str, Any]) -> dict[str, Any]: + """Redact identifiers from the data.""" + out_data = {} + for key, value in in_data.items(): + out_data[hash_identifier(key)] = value + return out_data + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: MieleConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + miele_data: dict[str, Any] = { + "devices": redact_identifiers( + { + device_id: device_data.raw + for device_id, device_data in config_entry.runtime_data.data.devices.items() + } + ), + "actions": redact_identifiers( + { + device_id: action_data.raw + for device_id, action_data in config_entry.runtime_data.data.actions.items() + } + ), + } + miele_data["missing_code_warnings"] = ( + sorted(completed_warnings) if len(completed_warnings) > 0 else ["None"] + ) + + return { + "config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT), + "miele_data": async_redact_data(miele_data, TO_REDACT), + } + + +async def async_get_device_diagnostics( + hass: HomeAssistant, config_entry: MieleConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device.""" + info = { + "manufacturer": device.manufacturer, + "model": device.model, + } + + coordinator = config_entry.runtime_data + + device_id = cast(str, device.serial_number) + miele_data: dict[str, Any] = { + "devices": { + hash_identifier(device_id): coordinator.data.devices[device_id].raw + }, + "actions": { + hash_identifier(device_id): coordinator.data.actions[device_id].raw + }, + "programs": "Not implemented", + } + miele_data["missing_code_warnings"] = ( + sorted(completed_warnings) if len(completed_warnings) > 0 else ["None"] + ) + + return { + "info": async_redact_data(info, TO_REDACT), + "data": async_redact_data(config_entry.data, TO_REDACT), + "miele_data": async_redact_data(miele_data, TO_REDACT), + } diff --git a/homeassistant/components/miele/entity.py b/homeassistant/components/miele/entity.py new file mode 100644 index 00000000000..f9ed4f0bf48 --- /dev/null +++ b/homeassistant/components/miele/entity.py @@ -0,0 +1,67 @@ +"""Entity base class for the Miele integration.""" + +from pymiele import MieleAction, MieleDevice + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .api import AsyncConfigEntryAuth +from .const import DEVICE_TYPE_TAGS, DOMAIN, MANUFACTURER, MieleAppliance, StateStatus +from .coordinator import MieleDataUpdateCoordinator + + +class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]): + """Base class for Miele entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._device_id = device_id + self.entity_description = description + self._attr_unique_id = f"{device_id}-{description.key}" + + device = self.device + appliance_type = DEVICE_TYPE_TAGS.get(MieleAppliance(device.device_type)) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + serial_number=device_id, + name=appliance_type or device.tech_type, + translation_key=appliance_type, + manufacturer=MANUFACTURER, + model=device.tech_type, + hw_version=device.xkm_tech_type, + sw_version=device.xkm_release_version, + ) + + @property + def device(self) -> MieleDevice: + """Return the device object.""" + return self.coordinator.data.devices[self._device_id] + + @property + def action(self) -> MieleAction: + """Return the actions object.""" + return self.coordinator.data.actions[self._device_id] + + @property + def api(self) -> AsyncConfigEntryAuth: + """Return the api object.""" + return self.coordinator.api + + @property + def available(self) -> bool: + """Return the availability of the entity.""" + + return ( + super().available + and self._device_id in self.coordinator.data.devices + and (self.device.state_status is not StateStatus.NOT_CONNECTED) + ) diff --git a/homeassistant/components/miele/fan.py b/homeassistant/components/miele/fan.py new file mode 100644 index 00000000000..fcd74a93bfb --- /dev/null +++ b/homeassistant/components/miele/fan.py @@ -0,0 +1,184 @@ +"""Platform for Miele fan entity.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +import math +from typing import Any, Final + +from aiohttp import ClientResponseError + +from homeassistant.components.fan import ( + FanEntity, + FanEntityDescription, + FanEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) +from homeassistant.util.scaling import int_states_in_range + +from .const import DOMAIN, POWER_OFF, POWER_ON, VENTILATION_STEP, MieleAppliance +from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator +from .entity import MieleEntity + +_LOGGER = logging.getLogger(__name__) + +SPEED_RANGE = (1, 4) + + +@dataclass(frozen=True, kw_only=True) +class MieleFanDefinition: + """Class for defining fan entities.""" + + types: tuple[MieleAppliance, ...] + description: FanEntityDescription + + +FAN_TYPES: Final[tuple[MieleFanDefinition, ...]] = ( + MieleFanDefinition( + types=(MieleAppliance.HOOD,), + description=FanEntityDescription( + key="fan", + translation_key="fan", + ), + ), + MieleFanDefinition( + types=(MieleAppliance.HOB_INDUCT_EXTR,), + description=FanEntityDescription( + key="fan_readonly", + translation_key="fan", + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the fan platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + MieleFan(coordinator, device_id, definition.description) + for device_id, device in coordinator.data.devices.items() + for definition in FAN_TYPES + if device.device_type in definition.types + ) + + +class MieleFan(MieleEntity, FanEntity): + """Representation of a Fan.""" + + entity_description: FanEntityDescription + + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: FanEntityDescription, + ) -> None: + """Initialize the fan.""" + + self._attr_supported_features: FanEntityFeature = ( + FanEntityFeature(0) + if description.key == "fan_readonly" + else FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + super().__init__(coordinator, device_id, description) + + @property + def is_on(self) -> bool: + """Return current on/off state.""" + return ( + self.device.state_ventilation_step is not None + and self.device.state_ventilation_step > 0 + ) + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) + + @property + def percentage(self) -> int | None: + """Return the current speed percentage.""" + return ranged_value_to_percentage( + SPEED_RANGE, + (self.device.state_ventilation_step or 0), + ) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + _LOGGER.debug("Set_percentage: %s", percentage) + ventilation_step = math.ceil( + percentage_to_ranged_value(SPEED_RANGE, percentage) + ) + _LOGGER.debug("Calc ventilation_step: %s", ventilation_step) + if ventilation_step == 0: + await self.async_turn_off() + else: + try: + await self.api.send_action( + self._device_id, {VENTILATION_STEP: ventilation_step} + ) + except ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from ex + self.device.state_ventilation_step = ventilation_step + self.async_write_ha_state() + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + _LOGGER.debug( + "Turn_on -> percentage: %s, preset_mode: %s", percentage, preset_mode + ) + try: + await self.api.send_action(self._device_id, {POWER_ON: True}) + except ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from ex + + if percentage is not None: + await self.async_set_percentage(percentage) + return + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + try: + await self.api.send_action(self._device_id, {POWER_OFF: True}) + except ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from ex + + self.device.state_ventilation_step = 0 + self.async_write_ha_state() diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json new file mode 100644 index 00000000000..a0fb1daaedd --- /dev/null +++ b/homeassistant/components/miele/icons.json @@ -0,0 +1,69 @@ +{ + "entity": { + "binary_sensor": { + "notification_active": { + "default": "mdi:information" + }, + "mobile_start": { + "default": "mdi:cellphone-wireless" + }, + "remote_control": { + "default": "mdi:remote" + }, + "smart_grid": { + "default": "mdi:view-grid-plus-outline" + } + }, + "button": { + "start": { + "default": "mdi:play" + }, + "stop": { + "default": "mdi:stop" + }, + "pause": { + "default": "mdi:pause" + } + }, + "sensor": { + "core_temperature": { + "default": "mdi:thermometer-probe" + }, + "core_target_temperature": { + "default": "mdi:thermometer-probe" + }, + "program_id": { + "default": "mdi:selection-ellipse-arrow-inside" + }, + "program_phase": { + "default": "mdi:tray-full" + }, + "elapsed_time": { + "default": "mdi:timelapse" + }, + "start_time": { + "default": "mdi:clock-start" + }, + "spin_speed": { + "default": "mdi:sync" + }, + "program_type": { + "default": "mdi:state-machine" + }, + "remaining_time": { + "default": "mdi:clock-end" + } + }, + "switch": { + "power": { + "default": "mdi:power" + }, + "supercooling": { + "default": "mdi:snowflake-variant" + }, + "superfreezing": { + "default": "mdi:snowflake" + } + } + } +} diff --git a/homeassistant/components/miele/light.py b/homeassistant/components/miele/light.py new file mode 100644 index 00000000000..0fbc8124be8 --- /dev/null +++ b/homeassistant/components/miele/light.py @@ -0,0 +1,130 @@ +"""Platform for Miele light entity.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any, Final + +import aiohttp + +from homeassistant.components.light import ( + ColorMode, + LightEntity, + LightEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import AMBIENT_LIGHT, DOMAIN, LIGHT, LIGHT_OFF, LIGHT_ON, MieleAppliance +from .coordinator import MieleConfigEntry +from .entity import MieleDevice, MieleEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class MieleLightDescription(LightEntityDescription): + """Class describing Miele light entities.""" + + value_fn: Callable[[MieleDevice], StateType] + light_type: str + + +@dataclass +class MieleLightDefinition: + """Class for defining light entities.""" + + types: tuple[MieleAppliance, ...] + description: MieleLightDescription + + +LIGHT_TYPES: Final[tuple[MieleLightDefinition, ...]] = ( + MieleLightDefinition( + types=( + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.HOOD, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleLightDescription( + key="light", + value_fn=lambda value: value.state_light, + light_type=LIGHT, + translation_key="light", + ), + ), + MieleLightDefinition( + types=(MieleAppliance.HOOD,), + description=MieleLightDescription( + key="ambient_light", + value_fn=lambda value: value.state_ambient_light, + light_type=AMBIENT_LIGHT, + translation_key="ambient_light", + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the light platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + MieleLight(coordinator, device_id, definition.description) + for device_id, device in coordinator.data.devices.items() + for definition in LIGHT_TYPES + if device.device_type in definition.types + ) + + +class MieleLight(MieleEntity, LightEntity): + """Representation of a Light.""" + + entity_description: MieleLightDescription + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + + @property + def is_on(self) -> bool: + """Return current on/off state.""" + return self.entity_description.value_fn(self.device) == LIGHT_ON + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + await self.async_turn_light(LIGHT_ON) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self.async_turn_light(LIGHT_OFF) + + async def async_turn_light(self, mode: int) -> None: + """Set light to mode.""" + try: + await self.api.send_action( + self._device_id, {self.entity_description.light_type: mode} + ) + except aiohttp.ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from err diff --git a/homeassistant/components/miele/manifest.json b/homeassistant/components/miele/manifest.json new file mode 100644 index 00000000000..c0795922875 --- /dev/null +++ b/homeassistant/components/miele/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "miele", + "name": "Miele", + "codeowners": ["@astrandb"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/miele", + "iot_class": "cloud_push", + "loggers": ["pymiele"], + "quality_scale": "bronze", + "requirements": ["pymiele==0.4.3"], + "single_config_entry": true, + "zeroconf": ["_mieleathome._tcp.local."] +} diff --git a/homeassistant/components/miele/quality_scale.yaml b/homeassistant/components/miele/quality_scale.yaml new file mode 100644 index 00000000000..e9d229c6a1b --- /dev/null +++ b/homeassistant/components/miele/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions are defined. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + 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: done + test-before-setup: done + unique-config-entry: + status: done + comment: | + Handled by a setting in manifest.json as there is no account information in API + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration parameters + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: + status: exempt + comment: Handled by coordinator + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: done + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py new file mode 100644 index 00000000000..0631d9c81dd --- /dev/null +++ b/homeassistant/components/miele/sensor.py @@ -0,0 +1,591 @@ +"""Sensor platform for Miele integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Final, cast + +from pymiele import MieleDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + REVOLUTIONS_PER_MINUTE, + EntityCategory, + UnitOfEnergy, + UnitOfTemperature, + UnitOfTime, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import ( + STATE_PROGRAM_ID, + STATE_PROGRAM_PHASE, + STATE_PROGRAM_TYPE, + STATE_STATUS_TAGS, + MieleAppliance, + StateStatus, +) +from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator +from .entity import MieleEntity + +_LOGGER = logging.getLogger(__name__) + +DISABLED_TEMPERATURE = -32768 + + +def _convert_duration(value_list: list[int]) -> int | None: + """Convert duration to minutes.""" + return value_list[0] * 60 + value_list[1] if value_list else None + + +@dataclass(frozen=True, kw_only=True) +class MieleSensorDescription(SensorEntityDescription): + """Class describing Miele sensor entities.""" + + value_fn: Callable[[MieleDevice], StateType] + zone: int | None = None + + +@dataclass +class MieleSensorDefinition: + """Class for defining sensor entities.""" + + types: tuple[MieleAppliance, ...] + description: MieleSensorDescription + + +SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.HOB_HIGHLIGHT, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.HOOD, + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.DISH_WARMER, + MieleAppliance.HOB_INDUCTION, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.HOB_INDUCT_EXTR, + ), + description=MieleSensorDescription( + key="state_status", + translation_key="status", + value_fn=lambda value: value.state_status, + device_class=SensorDeviceClass.ENUM, + options=sorted(set(STATE_STATUS_TAGS.values())), + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.DISH_WARMER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_program_id", + translation_key="program_id", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: value.state_program_id, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.DISH_WARMER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_program_phase", + translation_key="program_phase", + value_fn=lambda value: value.state_program_phase, + device_class=SensorDeviceClass.ENUM, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.DISH_WARMER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_program_type", + translation_key="program_type", + value_fn=lambda value: value.state_program_type, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=sorted(set(STATE_PROGRAM_TYPE.values())), + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.WASHER_DRYER, + ), + description=MieleSensorDescription( + key="current_energy_consumption", + translation_key="energy_consumption", + value_fn=lambda value: value.current_energy_consumption, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.DISHWASHER, + MieleAppliance.WASHER_DRYER, + ), + description=MieleSensorDescription( + key="current_water_consumption", + translation_key="water_consumption", + value_fn=lambda value: value.current_water_consumption, + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfVolume.LITERS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.WASHER_DRYER, + ), + description=MieleSensorDescription( + key="state_spinning_speed", + translation_key="spin_speed", + value_fn=lambda value: value.state_spinning_speed, + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_remaining_time", + translation_key="remaining_time", + value_fn=lambda value: _convert_duration(value.state_remaining_time), + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.DISHWASHER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_elapsed_time", + translation_key="elapsed_time", + value_fn=lambda value: _convert_duration(value.state_elapsed_time), + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.DISH_WARMER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_start_time", + translation_key="start_time", + value_fn=lambda value: _convert_duration(value.state_start_time), + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfTime.HOURS, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.DISH_WARMER, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_temperature_1", + zone=1, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda value: cast(int, value.state_temperatures[0].temperature) + / 100.0, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.DISH_WARMER, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_temperature_2", + zone=2, + device_class=SensorDeviceClass.TEMPERATURE, + translation_key="temperature_zone_2", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda value: value.state_temperatures[1].temperature / 100.0, # type: ignore [operator] + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN_COMBI, + ), + description=MieleSensorDescription( + key="state_core_target_temperature", + translation_key="core_target_temperature", + zone=1, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=( + lambda value: cast( + int, value.state_core_target_temperature[0].temperature + ) + / 100.0 + ), + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN_COMBI, + ), + description=MieleSensorDescription( + key="state_core_temperature", + translation_key="core_temperature", + zone=1, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=( + lambda value: cast(int, value.state_core_temperature[0].temperature) + / 100.0 + ), + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data + + entities: list = [] + entity_class: type[MieleSensor] + for device_id, device in coordinator.data.devices.items(): + for definition in SENSOR_TYPES: + if device.device_type in definition.types: + match definition.description.key: + case "state_status": + entity_class = MieleStatusSensor + case "state_program_id": + entity_class = MieleProgramIdSensor + case "state_program_phase": + entity_class = MielePhaseSensor + case "state_program_type": + entity_class = MieleTypeSensor + case _: + entity_class = MieleSensor + if ( + definition.description.device_class == SensorDeviceClass.TEMPERATURE + and definition.description.value_fn(device) + == DISABLED_TEMPERATURE / 100 + ): + # Don't create entity if API signals that datapoint is disabled + continue + entities.append( + entity_class(coordinator, device_id, definition.description) + ) + + async_add_entities(entities) + + +APPLIANCE_ICONS = { + MieleAppliance.WASHING_MACHINE: "mdi:washing-machine", + MieleAppliance.TUMBLE_DRYER: "mdi:tumble-dryer", + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL: "mdi:tumble-dryer", + MieleAppliance.DISHWASHER: "mdi:dishwasher", + MieleAppliance.OVEN: "mdi:chef-hat", + MieleAppliance.OVEN_MICROWAVE: "mdi:chef-hat", + MieleAppliance.HOB_HIGHLIGHT: "mdi:pot-steam-outline", + MieleAppliance.STEAM_OVEN: "mdi:chef-hat", + MieleAppliance.MICROWAVE: "mdi:microwave", + MieleAppliance.COFFEE_SYSTEM: "mdi:coffee-maker", + MieleAppliance.HOOD: "mdi:turbine", + MieleAppliance.FRIDGE: "mdi:fridge-industrial-outline", + MieleAppliance.FREEZER: "mdi:fridge-industrial-outline", + MieleAppliance.FRIDGE_FREEZER: "mdi:fridge-outline", + MieleAppliance.ROBOT_VACUUM_CLEANER: "mdi:robot-vacuum", + MieleAppliance.WASHER_DRYER: "mdi:washing-machine", + MieleAppliance.DISH_WARMER: "mdi:heat-wave", + MieleAppliance.HOB_INDUCTION: "mdi:pot-steam-outline", + MieleAppliance.STEAM_OVEN_COMBI: "mdi:chef-hat", + MieleAppliance.WINE_CABINET: "mdi:glass-wine", + MieleAppliance.WINE_CONDITIONING_UNIT: "mdi:glass-wine", + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT: "mdi:glass-wine", + MieleAppliance.STEAM_OVEN_MICRO: "mdi:chef-hat", + MieleAppliance.DIALOG_OVEN: "mdi:chef-hat", + MieleAppliance.WINE_CABINET_FREEZER: "mdi:glass-wine", + MieleAppliance.HOB_INDUCT_EXTR: "mdi:pot-steam-outline", +} + + +class MieleSensor(MieleEntity, SensorEntity): + """Representation of a Sensor.""" + + entity_description: MieleSensorDescription + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.device) + + +class MieleStatusSensor(MieleSensor): + """Representation of the status sensor.""" + + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: MieleSensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, device_id, description) + self._attr_name = None + self._attr_icon = APPLIANCE_ICONS.get( + MieleAppliance(self.device.device_type), + "mdi:state-machine", + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return STATE_STATUS_TAGS.get(StateStatus(self.device.state_status)) + + @property + def available(self) -> bool: + """Return the availability of the entity.""" + # This sensor should always be available + return True + + +class MielePhaseSensor(MieleSensor): + """Representation of the program phase sensor.""" + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + ret_val = STATE_PROGRAM_PHASE.get(self.device.device_type, {}).get( + self.device.state_program_phase + ) + if ret_val is None: + _LOGGER.debug( + "Unknown program phase: %s on device type: %s", + self.device.state_program_phase, + self.device.device_type, + ) + return ret_val + + @property + def options(self) -> list[str]: + """Return the options list for the actual device type.""" + return sorted( + set(STATE_PROGRAM_PHASE.get(self.device.device_type, {}).values()) + ) + + +class MieleTypeSensor(MieleSensor): + """Representation of the program type sensor.""" + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + ret_val = STATE_PROGRAM_TYPE.get(int(self.device.state_program_type)) + if ret_val is None: + _LOGGER.debug( + "Unknown program type: %s on device type: %s", + self.device.state_program_type, + self.device.device_type, + ) + return ret_val + + +class MieleProgramIdSensor(MieleSensor): + """Representation of the program id sensor.""" + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + ret_val = STATE_PROGRAM_ID.get(self.device.device_type, {}).get( + self.device.state_program_id + ) + if ret_val is None: + _LOGGER.debug( + "Unknown program id: %s on device type: %s", + self.device.state_program_id, + self.device.device_type, + ) + return ret_val + + @property + def options(self) -> list[str]: + """Return the options list for the actual device type.""" + return sorted(set(STATE_PROGRAM_ID.get(self.device.device_type, {}).values())) diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json new file mode 100644 index 00000000000..032a214d442 --- /dev/null +++ b/homeassistant/components/miele/strings.json @@ -0,0 +1,885 @@ +{ + "application_credentials": { + "description": "Navigate to [\"Get involved\" at Miele developer site]({register_url}) to request credentials then enter them below." + }, + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + }, + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Miele integration needs to re-authenticate your account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "account_mismatch": "The used account does not match the original account", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "device": { + "coffee_system": { + "name": "Coffee system" + }, + "dishwasher": { + "name": "Dishwasher" + }, + "tumble_dryer": { + "name": "Tumble dryer" + }, + "fridge_freezer": { + "name": "Fridge freezer" + }, + "induction_hob": { + "name": "Induction hob" + }, + "oven": { + "name": "Oven" + }, + "oven_microwave": { + "name": "Oven microwave" + }, + "hob_highlight": { + "name": "Hob highlight" + }, + "steam_oven": { + "name": "Steam oven" + }, + "microwave": { + "name": "Microwave" + }, + "hood": { + "name": "Hood" + }, + "warming_drawer": { + "name": "Warming drawer" + }, + "steam_oven_combi": { + "name": "Steam oven combi" + }, + "wine_cabinet": { + "name": "Wine cabinet" + }, + "wine_conditioning_unit": { + "name": "Wine conditioning unit" + }, + "wine_unit": { + "name": "Wine unit" + }, + "refrigerator": { + "name": "Refrigerator" + }, + "freezer": { + "name": "Freezer" + }, + "robot_vacuum_cleander": { + "name": "Robot vacuum cleaner" + }, + "steam_oven_microwave": { + "name": "Steam oven micro" + }, + "dialog_oven": { + "name": "Dialog oven" + }, + "wine_cabinet_freezer": { + "name": "Wine cabinet freezer" + }, + "hob_extraction": { + "name": "Hob with extraction" + }, + "washer_dryer": { + "name": "Washer dryer" + }, + "washing_machine": { + "name": "Washing machine" + } + }, + "entity": { + "binary_sensor": { + "failure": { + "name": "Failure" + }, + "info": { + "name": "Info" + }, + "notification_active": { + "name": "Notification active" + }, + "mobile_start": { + "name": "Mobile start" + }, + "remote_control": { + "name": "Remote control" + }, + "smart_grid": { + "name": "Smart grid" + } + }, + "button": { + "start": { + "name": "[%key:common::action::start%]" + }, + "stop": { + "name": "[%key:common::action::stop%]" + }, + "pause": { + "name": "[%key:common::action::pause%]" + } + }, + "fan": { + "fan": { + "name": "[%key:component::fan::title%]" + } + }, + "light": { + "ambient_light": { + "name": "Ambient light" + }, + "light": { + "name": "[%key:component::light::title%]" + } + }, + "climate": { + "freezer": { + "name": "[%key:component::miele::device::freezer::name%]" + }, + "refrigerator": { + "name": "[%key:component::miele::device::refrigerator::name%]" + }, + "wine_cabinet": { + "name": "[%key:component::miele::device::wine_cabinet::name%]" + }, + "zone_1": { + "name": "Zone 1" + }, + "zone_2": { + "name": "Zone 2" + }, + "zone_3": { + "name": "Zone 3" + } + }, + "sensor": { + "elapsed_time": { + "name": "Elapsed time" + }, + "remaining_time": { + "name": "Remaining time" + }, + "start_time": { + "name": "Start in" + }, + "energy_consumption": { + "name": "Energy consumption" + }, + "program_phase": { + "name": "Program phase", + "state": { + "2nd_espresso": "2nd espresso coffee", + "2nd_grinding": "2nd grinding", + "2nd_pre_brewing": "2nd pre-brewing", + "anti_crease": "Anti-crease", + "blocked_brushes": "Brushes blocked", + "blocked_drive_wheels": "Drive wheels blocked", + "blocked_front_wheel": "Front wheel blocked", + "cleaning": "Cleaning", + "comfort_cooling": "Comfort cooling", + "cooling_down": "Cooling down", + "dirty_sensors": "Dirty sensors", + "disinfecting": "Disinfecting", + "dispensing": "Dispensing", + "docked": "Docked", + "door_open": "Door open", + "drain": "Drain", + "drying": "Drying", + "dust_box_missing": "Missing dust box", + "energy_save": "Energy save", + "espresso": "Espresso coffee", + "extra_dry": "Extra dry", + "final_rinse": "Final rinse", + "finished": "Finished", + "freshen_up_and_moisten": "Freshen up & moisten", + "going_to_target_area": "Going to target area", + "grinding": "Grinding", + "hand_iron": "Hand iron", + "hand_iron_1": "Hand iron 1", + "hand_iron_2": "Hand iron 2", + "heating": "Heating", + "heating_up": "Heating up", + "heating_up_phase": "Heating up phase", + "hot_milk": "Hot milk", + "hygiene": "Hygiene", + "interim_rinse": "Interim rinse", + "keep_warm": "Keep warm", + "keeping_warm": "Keeping warm", + "machine_iron": "Machine iron", + "main_dishwash": "Cleaning", + "main_wash": "Main wash", + "milk_foam": "Milk foam", + "moisten": "Moisten", + "motor_overload": "Check dust box and filter", + "normal": "Normal", + "normal_plus": "Normal plus", + "not_running": "Not running", + "pre_brewing": "Pre-brewing", + "pre_dishwash": "Pre-cleaning", + "pre_wash": "Pre-wash", + "process_finished": "Process finished", + "process_running": "Process running", + "program_running": "Program running", + "reactivating": "Reactivating", + "remote_controlled": "Remote controlled", + "returning": "Returning", + "rinse": "Rinse", + "rinse_hold": "Rinse hold", + "rinse_out_lint": "Rinse out lint", + "rinses": "Rinses", + "safety_cooling": "Safety cooling", + "slightly_dry": "Slightly dry", + "slow_roasting": "Slow roasting", + "smoothing": "Smoothing", + "soak": "Soak", + "spin": "Spin", + "starch_stop": "Starch stop", + "steam_reduction": "Steam reduction", + "steam_smoothing": "Steam smoothing", + "thermo_spin": "Thermo spin", + "timed_drying": "Timed drying", + "vacuum_cleaning": "Cleaning", + "vacuum_cleaning_paused": "Cleaning paused", + "vacuum_internal_fault": "Internal fault - reboot", + "venting": "Venting", + "waiting_for_start": "Waiting for start", + "warm_air": "Warm air", + "warm_cups_glasses": "Warm cups/glasses", + "warm_dishes_plates": "Warm dishes/plates", + "wheel_lifted": "Wheel lifted" + } + }, + "program_type": { + "name": "Program type", + "state": { + "automatic_program": "Automatic program", + "cleaning_care_program": "Cleaning/care program", + "maintenance_program": "Maintenance program", + "normal_operation_mode": "Normal operation mode", + "own_program": "Own program" + } + }, + "program_id": { + "name": "Program", + "state": { + "1_tray": "1 tray", + "2_trays": "2 trays", + "amaranth": "Amaranth", + "apples_diced": "Apples (diced)", + "apples_halved": "Apples (halved)", + "apples_quartered": "Apples (quartered)", + "apples_sliced": "Apples (sliced)", + "apples_whole": "Apples (whole)", + "appliance_rinse": "Appliance rinse", + "appliance_settings": "Appliance settings menu", + "apricots_halved_skinning": "Apricots (halved, skinning)", + "apricots_halved_steam_cooking": "Apricots (halved, steam cooking)", + "apricots_quartered": "Apricots (quartered)", + "apricots_wedges": "Apricots (wedges)", + "artichokes_large": "Artichokes large", + "artichokes_medium": "Artichokes medium", + "artichokes_small": "Artichokes small", + "atlantic_catfish_fillet_1_cm": "Atlantic catfish (fillet, 1 cm)", + "atlantic_catfish_fillet_2_cm": "Atlantic catfish (fillet, 2 cm)", + "auto": "[%key:common::state::auto%]", + "auto_roast": "Auto roast", + "automatic": "Automatic", + "automatic_plus": "Automatic plus", + "baking_tray": "Baking tray", + "barista_assistant": "BaristaAssistant", + "basket_program": "Basket program", + "basmati_rice_rapid_steam_cooking": "Basmati rice (rapid steam cooking)", + "basmati_rice_steam_cooking": "Basmati rice (steam cooking)", + "bed_linen": "Bed linen", + "beef_casserole": "Beef casserole", + "beef_tenderloin": "Beef tenderloin", + "beef_tenderloin_medaillons_1_cm_low_temperature_cooking": "Beef tenderloin (medaillons, 1 cm, low-temperature cooking)", + "beef_tenderloin_medaillons_1_cm_steam_cooking": "Beef tenderloin (medaillons, 1 cm, steam cooking)", + "beef_tenderloin_medaillons_2_cm_low_temperature_cooking": "Beef tenderloin (medaillons, 2 cm, low-temperature cooking)", + "beef_tenderloin_medaillons_2_cm_steam_cooking": "Beef tenderloin (medaillons, 2 cm, steam cooking)", + "beef_tenderloin_medaillons_3_cm_low_temperature_cooking": "Beef tenderloin (medaillons, 3 cm, low-temperature cooking)", + "beef_tenderloin_medaillons_3_cm_steam_cooking": "Beef tenderloin (medaillons, 3 cm, steam cooking)", + "beetroot_whole_large": "Beetroot (whole, large)", + "beetroot_whole_medium": "Beetroot (whole, medium)", + "beetroot_whole_small": "Beetroot (whole, small)", + "beluga_lentils": "Beluga lentils", + "black_beans": "Black beans", + "black_salsify_medium": "Black salsify (medium)", + "black_salsify_thick": "Black salsify (thick)", + "black_salsify_thin": "Black salsify (thin)", + "black_tea": "Black tea", + "blanching": "Blanching", + "bologna_sausage": "Bologna sausage", + "bottling": "Bottling", + "bottling_hard": "Bottling (hard)", + "bottling_medium": "Bottling (medium)", + "bottling_soft": "Bottling (soft)", + "bottom_heat": "Bottom heat", + "bread_dumplings_boil_in_the_bag": "Bread dumplings (boil-in-the-bag)", + "bread_dumplings_fresh": "Bread dumplings (fresh)", + "brewing_unit_degrease": "Brewing unit degrease", + "broad_beans": "Broad beans", + "broccoli_florets_large": "Broccoli florets (large)", + "broccoli_florets_medium": "Broccoli florets (medium)", + "broccoli_florets_small": "Broccoli florets (small)", + "broccoli_whole_large": "Broccoli (whole, large)", + "broccoli_whole_medium": "Broccoli (whole, medium)", + "broccoli_whole_small": "Broccoli (whole, small)", + "brown_lentils": "Brown lentils", + "bruehwurst_sausages": "Brühwurst sausages", + "brussels_sprout": "Brussels sprout", + "bulgur": "Bulgur", + "bunched_carrots_cut_into_batons": "Bunched carrots (cut into batons)", + "bunched_carrots_diced": "Bunched carrots (diced)", + "bunched_carrots_halved": "Bunched carrots (halved)", + "bunched_carrots_quartered": "Bunched carrots (quartered)", + "bunched_carrots_sliced": "Bunched carrots (sliced)", + "bunched_carrots_whole_large": "Bunched carrots (whole, large)", + "bunched_carrots_whole_medium": "Bunched carrots (whole, medium)", + "bunched_carrots_whole_small": "Bunched carrots (whole, small)", + "cafe_au_lait": "Café au lait", + "caffe_latte": "Caffè latte", + "cappuccino": "Cappuccino", + "cappuccino_italiano": "Cappuccino Italiano", + "carp": "Carp", + "carrots_cut_into_batons": "Carrots (cut into batons)", + "carrots_diced": "Carrots (diced)", + "carrots_halved": "Carrots (halved)", + "carrots_quartered": "Carrots (quartered)", + "carrots_sliced": "Carrots (sliced)", + "carrots_whole_large": "Carrots (whole, large)", + "carrots_whole_medium": "Carrots (whole, medium)", + "carrots_whole_small": "Carrots (whole, small)", + "cauliflower_florets_large": "Cauliflower florets (large)", + "cauliflower_florets_medium": "Cauliflower florets (medium)", + "cauliflower_florets_small": "Cauliflower florets (small)", + "cauliflower_whole_large": "Cauliflower (whole, large)", + "cauliflower_whole_medium": "Cauliflower (whole, medium)", + "cauliflower_whole_small": "Cauliflower (whole, small)", + "celeriac_cut_into_batons": "Celeriac (cut into batons)", + "celeriac_diced": "Celeriac (diced)", + "celeriac_sliced": "Celeriac (sliced)", + "celery_pieces": "Celery (pieces)", + "celery_sliced": "Celery (sliced)", + "cep": "Cep", + "chanterelle": "Chanterelle", + "char": "Char", + "check_appliance": "Check appliance", + "cheesecake_one_large": "Cheesecake (one large)", + "cheesecake_several_small": "Cheesecake (several small)", + "chick_peas": "Chick peas", + "chicken_tikka_masala_with_rice": "Chicken Tikka Masala with rice", + "chinese_cabbage_cut": "Chinese cabbage (cut)", + "chongming_rapid_steam_cooking": "Chongming (rapid steam cooking)", + "chongming_steam_cooking": "Chongming (steam cooking)", + "christmas_pudding_cooking": "Christmas pudding (cooking)", + "christmas_pudding_heating": "Christmas pudding (heating)", + "clean_machine": "Clean machine", + "coalfish_fillet_2_cm": "Coalfish (fillet, 2 cm)", + "coalfish_fillet_3_cm": "Coalfish (fillet, 3 cm)", + "coalfish_piece": "Coalfish (piece)", + "cockles": "Cockles", + "codfish_fillet": "Codfish (fillet)", + "codfish_piece": "Codfish (piece)", + "coffee": "Coffee", + "coffee_pot": "Coffee pot", + "common_beans": "Common beans", + "common_sole_fillet_1_cm": "Common sole (fillet, 1 cm)", + "common_sole_fillet_2_cm": "Common sole (fillet, 2 cm)", + "conventional_heat": "Conventional heat", + "cook_bacon": "Cook bacon", + "cool_air": "Cool air", + "corn_on_the_cob": "Corn on the cob", + "cottons": "Cottons", + "cottons_eco": "Cottons ECO", + "cottons_hygiene": "Cottons hygiene", + "courgette_diced": "Courgette (diced)", + "courgette_sliced": "Courgette (sliced)", + "cranberries": "Cranberries", + "crevettes": "Crevettes", + "curtains": "Curtains", + "dark_garments": "Dark garments", + "decrystallise_honey": "Decrystallise honey", + "defrost": "Defrost", + "defrosting_with_microwave": "Defrosting with microwave", + "defrosting_with_steam": "Defrosting with steam", + "delicates": "Delicates", + "denim": "Denim", + "descale": "Descale", + "descaling": "Appliance descaling", + "dissolve_gelatine": "Dissolve gelatine", + "down_duvets": "Down duvets", + "down_filled_items": "Down-filled items", + "drain_spin": "Drain/spin", + "dutch_hash": "Dutch hash", + "eco": "ECO", + "eco_40_60": "ECO 40-60", + "eco_fan_heat": "ECO fan heat", + "eco_steam_cooking": "ECO steam cooking", + "economy_grill": "Economy grill", + "eggplant_diced": "Eggplant (diced)", + "eggplant_sliced": "Eggplant (sliced)", + "endive_halved": "Endive (halved)", + "endive_quartered": "Endive (quartered)", + "endive_strips": "Endive (strips)", + "espresso": "Espresso", + "espresso_macchiato": "Espresso macchiato", + "express": "Express", + "express_20": "Express 20'", + "extra_quiet": "Extra quiet", + "fan_grill": "Fan grill", + "fan_plus": "Fan plus", + "fennel_halved": "Fennel (halved)", + "fennel_quartered": "Fennel (quartered)", + "fennel_strips": "Fennel (strips)", + "first_wash": "First wash", + "flat_white": "Flat white", + "freshen_up": "Freshen up", + "fruit_tea": "Fruit tea", + "full_grill": "Full grill", + "gentle": "Gentle", + "gentle_denim": "Gentle denim", + "gentle_minimum_iron": "Gentle minimum iron", + "gentle_smoothing": "Gentle smoothing", + "german_turnip_cut_into_batons": "German turnip (cut into batons)", + "german_turnip_sliced": "German turnip (sliced)", + "gilt_head_bream_fillet": "Gilt-head bream (fillet)", + "gilt_head_bream_whole": "Gilt-head bream (whole)", + "glasses_warm": "Glasses warm", + "gnocchi_fresh": "Gnocchi (fresh)", + "goose_barnacles": "Goose barnacles", + "gooseberries": "Gooseberries", + "goulash_soup": "Goulash soup", + "green_asparagus_medium": "Green asparagus (medium)", + "green_asparagus_thick": "Green asparagus (thick)", + "green_asparagus_thin": "Green asparagus (thin)", + "green_beans_cut": "Green beans (cut)", + "green_beans_whole": "Green beans (whole)", + "green_cabbage_cut": "Green cabbage (cut)", + "green_spelt_cracked": "Green spelt (cracked)", + "green_spelt_whole": "Green spelt (whole)", + "green_split_peas": "Green split peas", + "green_tea": "Green tea", + "greenage_plums": "Greenage plums", + "halibut_fillet_2_cm": "Halibut (fillet, 2 cm)", + "halibut_fillet_3_cm": "Halibut (fillet, 3 cm)", + "heat_crockery": "Heat crockery", + "heating_damp_flannels": "Heating damp flannels", + "hens_eggs_size_l_hard": "Hen’s eggs (size „L“, hard)", + "hens_eggs_size_l_medium": "Hen’s eggs (size „L“, medium)", + "hens_eggs_size_l_soft": "Hen’s eggs (size „L“, soft)", + "hens_eggs_size_m_hard": "Hen’s eggs (size „M“, hard)", + "hens_eggs_size_m_medium": "Hen’s eggs (size „M“, medium)", + "hens_eggs_size_m_soft": "Hen’s eggs (size „M“, soft)", + "hens_eggs_size_s_hard": "Hen’s eggs (size „S“, hard)", + "hens_eggs_size_s_medium": "Hen’s eggs (size „S“, medium)", + "hens_eggs_size_s_soft": "Hen’s eggs (size „S“, soft)", + "hens_eggs_size_xl_hard": "Hen’s eggs (size „XL“, hard)", + "hens_eggs_size_xl_medium": "Hen’s eggs (size „XL“, medium)", + "hens_eggs_size_xl_soft": "Hen’s eggs (size „XL“, soft)", + "herbal_tea": "Herbal tea", + "hot_milk": "Hot milk", + "hot_water": "Hot water", + "huanghuanian_rapid_steam_cooking": "Huanghuanian (rapid steam cooking)", + "huanghuanian_steam_cooking": "Huanghuanian (steam cooking)", + "hygiene": "Hygiene", + "intensive": "Intensive", + "intensive_bake": "Intensive bake", + "iridescent_shark_fillet": "Iridescent shark (fillet)", + "japanese_tea": "Japanese tea", + "jasmine_rice_rapid_steam_cooking": "Jasmine rice (rapid steam cooking)", + "jasmine_rice_steam_cooking": "Jasmine rice (steam cooking)", + "jerusalem_artichoke_diced": "Jerusalem artichoke (diced)", + "jerusalem_artichoke_sliced": "Jerusalem artichoke (sliced)", + "kale_cut": "Kale (cut)", + "kasseler_piece": "Kasseler (piece)", + "kasseler_slice": "Kasseler (slice)", + "keeping_warm": "Keeping warm", + "king_prawns": "King prawns", + "knuckle_of_pork_cured": "Knuckle of pork (cured)", + "knuckle_of_pork_fresh": "Knuckle of pork (fresh)", + "large_pillows": "Large pillows", + "large_shrimps": "Large shrimps", + "latte_macchiato": "Latte macchiato", + "leek_pieces": "Leek (pieces)", + "leek_rings": "Leek (rings)", + "long_coffee": "Long coffee", + "long_grain_rice_general_rapid_steam_cooking": "Long grain rice (general, rapid steam cooking)", + "long_grain_rice_general_steam_cooking": "Long grain rice (general, steam cooking)", + "maintenance": "Maintenance program", + "make_yoghurt": "Make yoghurt", + "mangel_cut": "Mangel (cut)", + "meat_for_soup_back_or_top_rib": "Meat for soup (back or top rib)", + "meat_for_soup_brisket": "Meat for soup (brisket)", + "meat_for_soup_leg_steak": "Meat for soup (leg steak)", + "meat_with_rice": "Meat with rice", + "melt_chocolate": "Melt chocolate", + "menu_cooking": "Menu cooking", + "microwave": "Microwave", + "milk_foam": "Milk foam", + "milk_pipework_clean": "Milk pipework clean", + "milk_pipework_rinse": "Milk pipework rinse", + "millet": "Millet", + "minimum_iron": "Minimum iron", + "mirabelles": "Mirabelles", + "moisture_plus_auto_roast": "Moisture plus + Auto roast", + "moisture_plus_conventional_heat": "Moisture plus + Conventional heat", + "moisture_plus_fan_plus": "Moisture plus + Fan plus", + "moisture_plus_intensive_bake": "Moisture plus + Intensive bake", + "mushrooms_diced": "Mushrooms (diced)", + "mushrooms_halved": "Mushrooms (halved)", + "mushrooms_quartered": "Mushrooms (quartered)", + "mushrooms_sliced": "Mushrooms (sliced)", + "mushrooms_whole": "Mushrooms (whole)", + "mussels": "Mussels", + "mussels_in_sauce": "Mussels in sauce", + "nectarines_peaches_halved_skinning": "Nectarines/peaches (halved, skinning)", + "nectarines_peaches_halved_steam_cooking": "Nectarines/peaches (halved, steam cooking)", + "nectarines_peaches_quartered": "Nectarines/peaches (quartered)", + "nectarines_peaches_wedges": "Nectarines/peaches (wedges)", + "nile_perch_fillet_2_cm": "Nile perch (fillet, 2 cm)", + "nile_perch_fillet_3_cm": "Nile perch (fillet, 3 cm)", + "no_program": "No program", + "normal": "[%key:common::state::normal%]", + "oats_cracked": "Oats (cracked)", + "oats_whole": "Oats (whole)", + "outerwear": "Outerwear", + "oyster_mushroom_diced": "Oyster mushroom (diced)", + "oyster_mushroom_strips": "Oyster mushroom (strips)", + "oyster_mushroom_whole": "Oyster mushroom (whole)", + "parboiled_rice_rapid_steam_cooking": "Parboiled rice (rapid steam cooking)", + "parboiled_rice_steam_cooking": "Parboiled rice (steam cooking)", + "parisian_carrots_large": "Parisian carrots (large)", + "parisian_carrots_medium": "Parisian carrots (medium)", + "parisian_carrots_small": "Parisian carrots (small)", + "parsley_root_cut_into_batons": "Parsley root (cut into batons)", + "parsley_root_diced": "Parsley root (diced)", + "parsley_root_sliced": "Parsley root (sliced)", + "parsnip_cut_into_batons": "Parsnip (cut into batons)", + "parsnip_diced": "Parsnip (diced)", + "parsnip_sliced": "Parsnip (sliced)", + "pasta_paela": "Pasta/Paela", + "pears_halved": "Pears (halved)", + "pears_quartered": "Pears (quartered)", + "pears_to_cook_large_halved": "Pears to cook (large, halved)", + "pears_to_cook_large_quartered": "Pears to cook (large, quartered)", + "pears_to_cook_large_whole": "Pears to cook (large, whole)", + "pears_to_cook_medium_halved": "Pears to cook (medium, halved)", + "pears_to_cook_medium_quartered": "Pears to cook (medium, quartered)", + "pears_to_cook_medium_whole": "Pears to cook (medium, whole)", + "pears_to_cook_small_halved": "Pears to cook (small, halved)", + "pears_to_cook_small_quartered": "Pears to cook (small, quartered)", + "pears_to_cook_small_whole": "Pears to cook (small, whole)", + "pears_wedges": "Pears (wedges)", + "peas": "Peas", + "pepper_diced": "Pepper (diced)", + "pepper_halved": "Pepper (halved)", + "pepper_quartered": "Pepper (quartered)", + "pepper_strips": "Pepper (strips)", + "perch_fillet_2_cm": "Perch (fillet, 2 cm)", + "perch_fillet_3_cm": "Perch (fillet, 3 cm)", + "perch_whole": "Perch (whole)", + "pike_fillet": "Pike (fillet)", + "pike_piece": "Pike (piece)", + "pillows": "Pillows", + "pinto_beans": "Pinto beans", + "plaice_fillet_1_cm": "Plaice (fillet, 1 cm)", + "plaice_fillet_2_cm": "Plaice (fillet, 2 cm)", + "plaice_whole_2_cm": "Plaice (whole, 2 cm)", + "plaice_whole_3_cm": "Plaice (whole, 3 cm)", + "plaice_whole_4_cm": "Plaice (whole, 4 cm)", + "plums_halved": "Plums (halved)", + "plums_whole": "Plums (whole)", + "pointed_cabbage_cut": "Pointed cabbage (cut)", + "polenta": "Polenta", + "polenta_swiss_style_coarse_polenta": "Polenta Swiss style (coarse polenta)", + "polenta_swiss_style_fine_polenta": "Polenta Swiss style (fine polenta)", + "polenta_swiss_style_medium_polenta": "Polenta Swiss style (medium polenta)", + "popcorn": "Popcorn", + "pork_tenderloin_medaillons_3_cm": "Pork tenderloin (medaillons, 3 cm)", + "pork_tenderloin_medaillons_4_cm": "Pork tenderloin (medaillons, 4 cm)", + "pork_tenderloin_medaillons_5_cm": "Pork tenderloin (medaillons, 5 cm)", + "potato_dumplings_half_half_boil_in_bag": "Potato dumplings (half/half, boil-in-bag)", + "potato_dumplings_half_half_deep_frozen": "Potato dumplings (half/half, deep-frozen)", + "potato_dumplings_raw_boil_in_bag": "Potato dumplings (raw, boil-in-bag)", + "potato_dumplings_raw_deep_frozen": "Potato dumplings (raw, deep-frozen)", + "potatoes_floury_diced": "Potatoes (floury, diced)", + "potatoes_floury_halved": "Potatoes (floury, halved)", + "potatoes_floury_quartered": "Potatoes (floury, quartered)", + "potatoes_floury_whole_large": "Potatoes (floury, whole, large)", + "potatoes_floury_whole_medium": "Potatoes (floury, whole, medium)", + "potatoes_floury_whole_small": "Potatoes (floury, whole, small)", + "potatoes_in_the_skin_floury_large": "Potatoes (in the skin, floury, large)", + "potatoes_in_the_skin_floury_medium": "Potatoes (in the skin, floury, medium)", + "potatoes_in_the_skin_floury_small": "Potatoes (in the skin, floury, small)", + "potatoes_in_the_skin_mainly_waxy_large": "Potatoes (in the skin, mainly waxy, large)", + "potatoes_in_the_skin_mainly_waxy_medium": "Potatoes (in the skin, mainly waxy, medium)", + "potatoes_in_the_skin_mainly_waxy_small": "Potatoes (in the skin, mainly waxy, small)", + "potatoes_in_the_skin_waxy_large_rapid_steam_cooking": "Potatoes (in the skin, waxy, large, rapid steam cooking)", + "potatoes_in_the_skin_waxy_large_steam_cooking": "Potatoes (in the skin, waxy, large, steam cooking)", + "potatoes_in_the_skin_waxy_medium_rapid_steam_cooking": "Potatoes (in the skin, waxy, medium, rapid steam cooking)", + "potatoes_in_the_skin_waxy_medium_steam_cooking": "Potatoes (in the skin, waxy, medium, steam cooking)", + "potatoes_in_the_skin_waxy_small_rapid_steam_cooking": "Potatoes (in the skin, waxy, small, rapid steam cooking)", + "potatoes_in_the_skin_waxy_small_steam_cooking": "Potatoes (in the skin, waxy, small, steam cooking)", + "potatoes_mainly_waxy_diced": "Potatoes (mainly waxy, diced)", + "potatoes_mainly_waxy_halved": "Potatoes (mainly waxy, halved)", + "potatoes_mainly_waxy_large": "Potatoes (mainly waxy, large)", + "potatoes_mainly_waxy_medium": "Potatoes (mainly waxy, medium)", + "potatoes_mainly_waxy_quartered": "Potatoes (mainly waxy, quartered)", + "potatoes_mainly_waxy_small": "Potatoes (mainly waxy, small)", + "potatoes_waxy_diced": "Potatoes (waxy, diced)", + "potatoes_waxy_halved": "Potatoes (waxy, halved)", + "potatoes_waxy_quartered": "Potatoes (waxy, quartered)", + "potatoes_waxy_whole_large": "Potatoes (waxy, whole, large)", + "potatoes_waxy_whole_medium": "Potatoes (waxy, whole, medium)", + "potatoes_waxy_whole_small": "Potatoes (waxy, whole, small)", + "poularde_breast": "Poularde breast", + "poularde_whole": "Poularde (whole)", + "power_wash": "PowerWash", + "prawns": "Prawns", + "proofing": "Proofing", + "prove_15_min": "Prove for 15 min", + "prove_30_min": "Prove for 30 min", + "prove_45_min": "Prove for 45 min", + "prove_dough": "Prove dough", + "pumpkin_diced": "Pumpkin (diced)", + "pumpkin_soup": "Pumpkin soup", + "quick_mw": "Quick MW", + "quick_power_wash": "QuickPowerWash", + "quinces_diced": "Quinces (diced)", + "quinoa": "Quinoa", + "rapid_steam_cooking": "Rapid steam cooking", + "ravioli_fresh": "Ravioli (fresh)", + "razor_clams_large": "Razor clams (large)", + "razor_clams_medium": "Razor clams (medium)", + "razor_clams_small": "Razor clams (small)", + "red_beans": "Red beans", + "red_cabbage_cut": "Red cabbage (cut)", + "red_lentils": "Red lentils", + "red_snapper_fillet_2_cm": "Red snapper (fillet, 2 cm)", + "red_snapper_fillet_3_cm": "Red snapper (fillet, 3 cm)", + "redfish_fillet_2_cm": "Redfish (fillet, 2 cm)", + "redfish_fillet_3_cm": "Redfish (fillet, 3 cm)", + "redfish_piece": "Redfish (piece)", + "reheating_with_microwave": "Reheating with microwave", + "reheating_with_steam": "Reheating with steam", + "rhubarb_chunks": "Rhubarb chunks", + "rice_pudding_rapid_steam_cooking": "Rice pudding (rapid steam cooking)", + "rice_pudding_steam_cooking": "Rice pudding (steam cooking)", + "rinse": "Rinse", + "rinse_out_lint": "Rinse out lint", + "risotto": "Risotto", + "ristretto": "Ristretto", + "romanesco_florets_large": "Romanesco florets (large)", + "romanesco_florets_medium": "Romanesco florets (medium)", + "romanesco_florets_small": "Romanesco florets (small)", + "romanesco_whole_large": "Romanesco (whole, large)", + "romanesco_whole_medium": "Romanesco (whole, medium)", + "romanesco_whole_small": "Romanesco (whole, small)", + "round_grain_rice_general_rapid_steam_cooking": "Round grain rice (general, rapid steam cooking)", + "round_grain_rice_general_steam_cooking": "Round grain rice (general, steam cooking)", + "runner_beans_pieces": "Runner beans (pieces)", + "runner_beans_sliced": "Runner beans (sliced)", + "runner_beans_whole": "Runner beans (whole)", + "rye_cracked": "Rye (cracked)", + "rye_whole": "Rye (whole)", + "salmon_fillet_2_cm": "Salmon (fillet, 2 cm)", + "salmon_fillet_3_cm": "Salmon (fillet, 3 cm)", + "salmon_piece": "Salmon (piece)", + "salmon_steak_2_cm": "Salmon (steak, 2 cm)", + "salmon_steak_3_cm": "Salmon (steak, 3 cm)", + "salmon_trout": "Salmon trout", + "saucisson": "Saucisson", + "savoy_cabbage_cut": "Savoy cabbage (cut)", + "scallops": "Scallops", + "schupfnudeln_potato_noodels": "Schupfnudeln (potato noodels)", + "sea_devil_fillet_3_cm": "Sea devil (fillet, 3 cm)", + "sea_devil_fillet_4_cm": "Sea devil (fillet, 4 cm)", + "separate_rinse_starch": "Separate rinse/starch", + "sheyang_rapid_steam_cooking": "Sheyang (rapid steam cooking)", + "sheyang_steam_cooking": "Sheyang (steam cooking)", + "shirts": "Shirts", + "silent": "Silent", + "silks": "Silks", + "silks_handcare": "Silks handcare", + "silverside_10_cm": "Silverside (10 cm)", + "silverside_5_cm": "Silverside (5 cm)", + "silverside_7_5_cm": "Silverside (7.5 cm)", + "simiao_rapid_steam_cooking": "Simiao (rapid steam cooking)", + "simiao_steam_cooking": "Simiao (steam cooking)", + "small_shrimps": "Small shrimps", + "smoothing": "Smoothing", + "snow_pea": "Snow pea", + "soak": "Soak", + "solar_save": "SolarSave", + "soup_hen": "Soup hen", + "sour_cherries": "Sour cherries", + "sous_vide": "Sous-vide", + "spaetzle_fresh": "Spätzle (fresh)", + "spelt_cracked": "Spelt (cracked)", + "spelt_whole": "Spelt (whole)", + "spinach": "Spinach", + "sportswear": "Sportswear", + "spot": "Spot", + "standard_pillows": "Standard pillows", + "starch": "Starch", + "steam_care": "Steam care", + "steam_cooking": "Steam cooking", + "steam_smoothing": "Steam smoothing", + "stuffed_cabbage": "Stuffed cabbage", + "sweat_onions": "Sweat onions", + "swede_cut_into_batons": "Swede (cut into batons)", + "swede_diced": "Swede (diced)", + "sweet_cheese_dumplings": "Sweet cheese dumplings", + "sweet_cherries": "Sweet cherries", + "swiss_toffee_cream_100_ml": "Swiss toffee cream (100 ml)", + "swiss_toffee_cream_150_ml": "Swiss toffee cream (150 ml)", + "tagliatelli_fresh": "Tagliatelli (fresh)", + "tall_items": "Tall items", + "teltow_turnip_diced": "Teltow turnip (diced)", + "teltow_turnip_sliced": "Teltow turnip (sliced)", + "tilapia_fillet_1_cm": "Tilapia (fillet, 1 cm)", + "tilapia_fillet_2_cm": "Tilapia (fillet, 2 cm)", + "toffee_date_dessert_one_large": "Toffee-date dessert (one large)", + "toffee_date_dessert_several_small": "Toffee-date dessert (several small)", + "top_heat": "Top heat", + "tortellini_fresh": "Tortellini (fresh)", + "trainers": "Trainers", + "treacle_sponge_pudding_one_large": "Treacle sponge pudding (one large)", + "treacle_sponge_pudding_several_small": "Treacle sponge pudding (several small)", + "trout": "Trout", + "tuna_fillet_2_cm": "Tuna (fillet, 2 cm)", + "tuna_fillet_3_cm": "Tuna (fillet, 3 cm)", + "tuna_steak": "Tuna (steak)", + "turbo": "Turbo", + "turbot_fillet_2_cm": "Turbot (fillet, 2 cm)", + "turbot_fillet_3_cm": "Turbot (fillet, 3 cm)", + "turkey_breast": "Turkey breast", + "uonumma_koshihikari_rapid_steam_cooking": "Uonumma Koshihikari (rapid steam cooking)", + "uonumma_koshihikari_steam_cooking": "Uonumma Koshihikari (steam cooking)", + "veal_fillet_medaillons_1_cm": "Veal fillet (medaillons, 1 cm)", + "veal_fillet_medaillons_2_cm": "Veal fillet (medaillons, 2 cm)", + "veal_fillet_medaillons_3_cm": "Veal fillet (medaillons, 3 cm)", + "veal_fillet_whole": "Veal fillet (whole)", + "veal_sausages": "Veal sausages", + "venus_clams": "Venus clams", + "very_hot_water": "Very hot water", + "viennese_silverside": "Viennese silverside", + "warm_air": "Warm air", + "wheat_cracked": "Wheat (cracked)", + "wheat_whole": "Wheat (whole)", + "white_asparagus_medium": "White asparagus (medium)", + "white_asparagus_thick": "White asparagus (thick)", + "white_asparagus_thin": "White asparagus (thin)", + "white_beans": "White beans", + "white_tea": "White tea", + "whole_ham_reheating": "Whole ham (reheating)", + "whole_ham_steam_cooking": "Whole ham (steam cooking)", + "wholegrain_rice": "Wholegrain rice", + "wild_rice": "Wild rice", + "woollens": "Woollens", + "woollens_handcare": "Woollens hand care", + "wuchang_rapid_steam_cooking": "Wuchang (rapid steam cooking)", + "wuchang_steam_cooking": "Wuchang (steam cooking)", + "yam_halved": "Yam (halved)", + "yam_quartered": "Yam (quartered)", + "yam_strips": "Yam (strips)", + "yeast_dumplings_fresh": "Yeast dumplings (fresh)", + "yellow_beans_cut": "Yellow beans (cut)", + "yellow_beans_whole": "Yellow beans (whole)", + "yellow_split_peas": "Yellow split peas", + "zander_fillet": "Zander (fillet)" + } + }, + "spin_speed": { + "name": "Spin speed" + }, + "status": { + "name": "Status", + "state": { + "autocleaning": "Automatic cleaning", + "failure": "Failure", + "idle": "[%key:common::state::idle%]", + "not_connected": "Not connected", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "pause": "Pause", + "program_ended": "Program ended", + "program_interrupted": "Program interrupted", + "programmed": "Programmed", + "rinse_hold": "Rinse hold", + "in_use": "In use", + "service": "Service", + "supercooling": "Supercooling", + "supercooling_superfreezing": "Supercooling/superfreezing", + "superfreezing": "Superfreezing", + "superheating": "Superheating", + "waiting_to_start": "Waiting to start" + } + }, + "temperature_zone_2": { + "name": "Temperature zone 2" + }, + "temperature_zone_3": { + "name": "Temperature zone 3" + }, + "water_consumption": { + "name": "Water consumption" + }, + "core_temperature": { + "name": "Core temperature" + }, + "core_target_temperature": { + "name": "Core target temperature" + } + }, + "switch": { + "power": { + "name": "Power" + }, + "supercooling": { + "name": "Supercooling" + }, + "superfreezing": { + "name": "Superfreezing" + } + } + }, + "exceptions": { + "config_entry_auth_failed": { + "message": "Authentication failed. Please log in again." + }, + "config_entry_not_ready": { + "message": "Error while loading the integration." + }, + "set_state_error": { + "message": "Failed to set state for {entity}." + } + } +} diff --git a/homeassistant/components/miele/switch.py b/homeassistant/components/miele/switch.py new file mode 100644 index 00000000000..427d90968b7 --- /dev/null +++ b/homeassistant/components/miele/switch.py @@ -0,0 +1,210 @@ +"""Switch platform for Miele switch integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any, Final, cast + +import aiohttp +from pymiele import MieleDevice + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import ( + DOMAIN, + POWER_OFF, + POWER_ON, + PROCESS_ACTION, + MieleActions, + MieleAppliance, + StateStatus, +) +from .coordinator import MieleConfigEntry +from .entity import MieleEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class MieleSwitchDescription(SwitchEntityDescription): + """Class describing Miele switch entities.""" + + value_fn: Callable[[MieleDevice], StateType] + on_value: int = 0 + off_value: int = 0 + on_cmd_data: dict[str, str | int | bool] + off_cmd_data: dict[str, str | int | bool] + + +@dataclass +class MieleSwitchDefinition: + """Class for defining switch entities.""" + + types: tuple[MieleAppliance, ...] + description: MieleSwitchDescription + + +SWITCH_TYPES: Final[tuple[MieleSwitchDefinition, ...]] = ( + MieleSwitchDefinition( + types=(MieleAppliance.FRIDGE, MieleAppliance.FRIDGE_FREEZER), + description=MieleSwitchDescription( + key="supercooling", + value_fn=lambda value: value.state_status, + on_value=StateStatus.SUPERCOOLING, + translation_key="supercooling", + on_cmd_data={PROCESS_ACTION: MieleActions.START_SUPERCOOL}, + off_cmd_data={PROCESS_ACTION: MieleActions.STOP_SUPERCOOL}, + ), + ), + MieleSwitchDefinition( + types=( + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.WINE_CABINET_FREEZER, + ), + description=MieleSwitchDescription( + key="superfreezing", + value_fn=lambda value: value.state_status, + on_value=StateStatus.SUPERFREEZING, + translation_key="superfreezing", + on_cmd_data={PROCESS_ACTION: MieleActions.START_SUPERFREEZE}, + off_cmd_data={PROCESS_ACTION: MieleActions.STOP_SUPERFREEZE}, + ), + ), + MieleSwitchDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.DISH_WARMER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.HOOD, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSwitchDescription( + key="poweronoff", + value_fn=lambda value: value.state_status, + off_value=1, + translation_key="power", + on_cmd_data={POWER_ON: True}, + off_cmd_data={POWER_OFF: True}, + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the switch platform.""" + coordinator = config_entry.runtime_data + + entities: list = [] + entity_class: type[MieleSwitch] + for device_id, device in coordinator.data.devices.items(): + for definition in SWITCH_TYPES: + if device.device_type in definition.types: + match definition.description.key: + case "poweronoff": + entity_class = MielePowerSwitch + case "supercooling" | "superfreezing": + entity_class = MieleSuperSwitch + + entities.append( + entity_class(coordinator, device_id, definition.description) + ) + async_add_entities(entities) + + +class MieleSwitch(MieleEntity, SwitchEntity): + """Representation of a Switch.""" + + entity_description: MieleSwitchDescription + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the device.""" + await self.async_turn_switch(self.entity_description.on_cmd_data) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the device.""" + await self.async_turn_switch(self.entity_description.off_cmd_data) + + async def async_turn_switch(self, mode: dict[str, str | int | bool]) -> None: + """Set switch to mode.""" + try: + await self.api.send_action(self._device_id, mode) + except aiohttp.ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from err + + +class MielePowerSwitch(MieleSwitch): + """Representation of a power switch.""" + + entity_description: MieleSwitchDescription + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + return self.action.power_off_enabled + + @property + def available(self) -> bool: + """Return the availability of the entity.""" + + return ( + self.action.power_off_enabled or self.action.power_on_enabled + ) and super().available + + async def async_turn_switch(self, mode: dict[str, str | int | bool]) -> None: + """Set switch to mode.""" + try: + await self.api.send_action(self._device_id, mode) + except aiohttp.ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from err + self.action.power_on_enabled = cast(bool, mode) + self.action.power_off_enabled = not cast(bool, mode) + self.async_write_ha_state() + + +class MieleSuperSwitch(MieleSwitch): + """Representation of a supercool/superfreeze switch.""" + + entity_description: MieleSwitchDescription + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + return ( + self.entity_description.value_fn(self.device) + == self.entity_description.on_value + ) diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index 2fcf2033930..246ea778916 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -14,7 +14,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL -from .coordinator import MillDataUpdateCoordinator +from .coordinator import MillDataUpdateCoordinator, MillHistoricDataUpdateCoordinator PLATFORMS = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR] @@ -41,6 +41,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: key = entry.data[CONF_USERNAME] conn_type = CLOUD + historic_data_coordinator = MillHistoricDataUpdateCoordinator( + hass, + mill_data_connection=mill_data_connection, + ) + historic_data_coordinator.async_add_listener(lambda: None) + await historic_data_coordinator.async_config_entry_first_refresh() try: if not await mill_data_connection.connect(): raise ConfigEntryNotReady diff --git a/homeassistant/components/mill/coordinator.py b/homeassistant/components/mill/coordinator.py index ae527f8cce5..288b341b0f9 100644 --- a/homeassistant/components/mill/coordinator.py +++ b/homeassistant/components/mill/coordinator.py @@ -4,18 +4,30 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import cast -from mill import Mill +from mill import Heater, Mill from mill_local import Mill as MillLocal +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, + statistics_during_period, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util, slugify from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +TWO_YEARS = 2 * 365 * 24 + class MillDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching Mill data.""" @@ -40,3 +52,104 @@ class MillDataUpdateCoordinator(DataUpdateCoordinator): update_method=mill_data_connection.fetch_heater_and_sensor_data, update_interval=update_interval, ) + + +class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Mill historic data.""" + + def __init__( + self, + hass: HomeAssistant, + *, + mill_data_connection: Mill, + ) -> None: + """Initialize global Mill data updater.""" + self.mill_data_connection = mill_data_connection + + super().__init__( + hass, + _LOGGER, + name="MillHistoricDataUpdateCoordinator", + ) + + async def _async_update_data(self): + """Update historic data via API.""" + now = dt_util.utcnow() + self.update_interval = ( + timedelta(hours=1) + now.replace(minute=1, second=0) - now + ) + + recoder_instance = get_instance(self.hass) + for dev_id, heater in self.mill_data_connection.devices.items(): + if not isinstance(heater, Heater): + continue + statistic_id = f"{DOMAIN}:energy_{slugify(dev_id)}" + + last_stats = await recoder_instance.async_add_executor_job( + get_last_statistics, self.hass, 1, statistic_id, True, set() + ) + if not last_stats or not last_stats.get(statistic_id): + hourly_data = ( + await self.mill_data_connection.fetch_historic_energy_usage( + dev_id, n_days=TWO_YEARS + ) + ) + hourly_data = dict(sorted(hourly_data.items(), key=lambda x: x[0])) + _sum = 0.0 + last_stats_time = None + else: + hourly_data = ( + await self.mill_data_connection.fetch_historic_energy_usage( + dev_id, + n_days=( + now + - dt_util.utc_from_timestamp( + last_stats[statistic_id][0]["start"] + ) + ).days + + 2, + ) + ) + if not hourly_data: + continue + hourly_data = dict(sorted(hourly_data.items(), key=lambda x: x[0])) + start_time = next(iter(hourly_data)) + stats = await recoder_instance.async_add_executor_job( + statistics_during_period, + self.hass, + start_time, + None, + {statistic_id}, + "hour", + None, + {"sum", "state"}, + ) + stat = stats[statistic_id][0] + + _sum = cast(float, stat["sum"]) - cast(float, stat["state"]) + last_stats_time = dt_util.utc_from_timestamp(stat["start"]) + + statistics = [] + + for start, state in hourly_data.items(): + if state is None: + continue + if (last_stats_time and start < last_stats_time) or start > now: + continue + _sum += state + statistics.append( + StatisticData( + start=start, + state=state, + sum=_sum, + ) + ) + metadata = StatisticMetaData( + has_mean=False, + has_sum=True, + name=f"{heater.name}", + source=DOMAIN, + statistic_id=statistic_id, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ) + async_add_external_statistics(self.hass, metadata, statistics) diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 44c1136b7d5..bfad9b48cb9 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -1,6 +1,7 @@ { "domain": "mill", "name": "Mill", + "after_dependencies": ["recorder"], "codeowners": ["@danielhiversen"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mill", diff --git a/homeassistant/components/mjpeg/config_flow.py b/homeassistant/components/mjpeg/config_flow.py index e0150f8c461..a0efb56c224 100644 --- a/homeassistant/components/mjpeg/config_flow.py +++ b/homeassistant/components/mjpeg/config_flow.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping from http import HTTPStatus -from types import MappingProxyType from typing import Any import requests @@ -34,7 +34,7 @@ from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, LOGGER @callback def async_get_schema( - defaults: dict[str, Any] | MappingProxyType[str, Any], show_name: bool = False + defaults: Mapping[str, Any], show_name: bool = False ) -> vol.Schema: """Return MJPEG IP Camera schema.""" schema = { diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 347549dc837..7d1578558b0 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -88,11 +88,11 @@ }, "duplicate_entity_entry": { "title": "Modbus {sub_1} address {sub_2} is duplicate, second entry not loaded.", - "description": "An address can only be associated with one entity, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." + "description": "An address can only be associated with one entity. Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." }, "duplicate_entity_name": { "title": "Modbus {sub_1} is duplicate, second entry not loaded.", - "description": "A entity name must be unique, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." + "description": "An entity name must be unique. Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." }, "no_entities": { "title": "Modbus {sub_1} contain no entities, entry not loaded.", diff --git a/homeassistant/components/motionblinds_ble/strings.json b/homeassistant/components/motionblinds_ble/strings.json index ec1fb080854..4589c2d873b 100644 --- a/homeassistant/components/motionblinds_ble/strings.json +++ b/homeassistant/components/motionblinds_ble/strings.json @@ -62,9 +62,9 @@ "speed": { "name": "Speed", "state": { - "1": "Low", - "2": "Medium", - "3": "High" + "1": "[%key:common::state::low%]", + "2": "[%key:common::state::medium%]", + "3": "[%key:common::state::high%]" } } }, @@ -72,8 +72,8 @@ "connection": { "name": "Connection status", "state": { - "connected": "Connected", - "disconnected": "Disconnected", + "connected": "[%key:common::state::connected%]", + "disconnected": "[%key:common::state::disconnected%]", "connecting": "Connecting", "disconnecting": "Disconnecting" } diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index 159956277a8..adf380bf9eb 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping from contextlib import suppress -from types import MappingProxyType from typing import Any import aiohttp @@ -154,7 +154,7 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): camera: dict[str, Any], client: MotionEyeClient, coordinator: DataUpdateCoordinator, - options: MappingProxyType[str, str], + options: Mapping[str, str], ) -> None: """Initialize a MJPEG camera.""" self._surveillance_username = username diff --git a/homeassistant/components/motioneye/entity.py b/homeassistant/components/motioneye/entity.py index 49739f2fca3..e279533f080 100644 --- a/homeassistant/components/motioneye/entity.py +++ b/homeassistant/components/motioneye/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from types import MappingProxyType +from collections.abc import Mapping from typing import Any from motioneye_client.client import MotionEyeClient @@ -37,7 +37,7 @@ class MotionEyeEntity(CoordinatorEntity): camera: dict[str, Any], client: MotionEyeClient, coordinator: DataUpdateCoordinator, - options: MappingProxyType[str, Any], + options: Mapping[str, Any], entity_description: EntityDescription | None = None, ) -> None: """Initialize a motionEye entity.""" diff --git a/homeassistant/components/motioneye/sensor.py b/homeassistant/components/motioneye/sensor.py index c160b77c16a..c8d05c6bb4d 100644 --- a/homeassistant/components/motioneye/sensor.py +++ b/homeassistant/components/motioneye/sensor.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping import logging -from types import MappingProxyType from typing import Any from motioneye_client.client import MotionEyeClient @@ -60,7 +60,7 @@ class MotionEyeActionSensor(MotionEyeEntity, SensorEntity): camera: dict[str, Any], client: MotionEyeClient, coordinator: DataUpdateCoordinator, - options: MappingProxyType[str, str], + options: Mapping[str, str], ) -> None: """Initialize an action sensor.""" super().__init__( diff --git a/homeassistant/components/motioneye/switch.py b/homeassistant/components/motioneye/switch.py index 89d3b8a8727..afa0b9481d1 100644 --- a/homeassistant/components/motioneye/switch.py +++ b/homeassistant/components/motioneye/switch.py @@ -2,7 +2,7 @@ from __future__ import annotations -from types import MappingProxyType +from collections.abc import Mapping from typing import Any from motioneye_client.client import MotionEyeClient @@ -103,7 +103,7 @@ class MotionEyeSwitch(MotionEyeEntity, SwitchEntity): camera: dict[str, Any], client: MotionEyeClient, coordinator: DataUpdateCoordinator, - options: MappingProxyType[str, str], + options: Mapping[str, str], entity_description: SwitchEntityDescription, ) -> None: """Initialize the switch.""" diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index a8fcc84f2ec..861faa319cd 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -46,6 +46,7 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-preset" self._presets: list[motionmount.Preset] = [] + self._attr_current_option = None def _update_options(self, presets: list[motionmount.Preset]) -> None: """Convert presets to select options.""" diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index 75fd0773322..2c951a7aefe 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -68,7 +68,7 @@ }, "sensor": { "motionmount_error_status": { - "name": "Error Status", + "name": "Error status", "state": { "none": "None", "motor": "Motor", diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index a9037a5f247..f0d000f79db 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -56,6 +56,7 @@ ABBREVIATIONS = { "ent_pic": "entity_picture", "evt_typ": "event_types", "fanspd_lst": "fan_speed_list", + "flsh": "flash", "flsh_tlng": "flash_time_long", "flsh_tsht": "flash_time_short", "fx_cmd_tpl": "effect_command_template", @@ -253,6 +254,7 @@ ABBREVIATIONS = { "tilt_status_tpl": "tilt_status_template", "tit": "title", "t": "topic", + "trns": "transition", "uniq_id": "unique_id", "unit_of_meas": "unit_of_measurement", "url_t": "url_topic", diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 5947bfb3da9..74f55afabaa 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -27,6 +27,12 @@ import voluptuous as vol from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonManager, AddonState +from homeassistant.components.light import ( + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, + VALID_COLOR_MODES, + valid_supported_color_modes, +) from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASS_UNITS, @@ -50,18 +56,23 @@ from homeassistant.const import ( ATTR_MODEL_ID, ATTR_NAME, ATTR_SW_VERSION, + CONF_BRIGHTNESS, CONF_CLIENT_ID, CONF_DEVICE, CONF_DEVICE_CLASS, CONF_DISCOVERY, + CONF_EFFECT, CONF_HOST, CONF_NAME, CONF_OPTIMISTIC, CONF_PASSWORD, CONF_PAYLOAD, + CONF_PAYLOAD_OFF, + CONF_PAYLOAD_ON, CONF_PLATFORM, CONF_PORT, CONF_PROTOCOL, + CONF_STATE_TEMPLATE, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, @@ -102,37 +113,97 @@ from .const import ( CONF_AVAILABILITY_TEMPLATE, CONF_AVAILABILITY_TOPIC, CONF_BIRTH_MESSAGE, + CONF_BLUE_TEMPLATE, + CONF_BRIGHTNESS_COMMAND_TEMPLATE, + CONF_BRIGHTNESS_COMMAND_TOPIC, + CONF_BRIGHTNESS_SCALE, + CONF_BRIGHTNESS_STATE_TOPIC, + CONF_BRIGHTNESS_TEMPLATE, + CONF_BRIGHTNESS_VALUE_TEMPLATE, CONF_BROKER, CONF_CERTIFICATE, CONF_CLIENT_CERT, CONF_CLIENT_KEY, + CONF_COLOR_MODE_STATE_TOPIC, + CONF_COLOR_MODE_VALUE_TEMPLATE, + CONF_COLOR_TEMP_COMMAND_TEMPLATE, + CONF_COLOR_TEMP_COMMAND_TOPIC, + CONF_COLOR_TEMP_KELVIN, + CONF_COLOR_TEMP_STATE_TOPIC, + CONF_COLOR_TEMP_TEMPLATE, + CONF_COLOR_TEMP_VALUE_TEMPLATE, + CONF_COMMAND_OFF_TEMPLATE, + CONF_COMMAND_ON_TEMPLATE, CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_DISCOVERY_PREFIX, + CONF_EFFECT_COMMAND_TEMPLATE, + CONF_EFFECT_COMMAND_TOPIC, + CONF_EFFECT_LIST, + CONF_EFFECT_STATE_TOPIC, + CONF_EFFECT_TEMPLATE, + CONF_EFFECT_VALUE_TEMPLATE, CONF_ENTITY_PICTURE, CONF_EXPIRE_AFTER, + CONF_FLASH, + CONF_FLASH_TIME_LONG, + CONF_FLASH_TIME_SHORT, + CONF_GREEN_TEMPLATE, + CONF_HS_COMMAND_TEMPLATE, + CONF_HS_COMMAND_TOPIC, + CONF_HS_STATE_TOPIC, + CONF_HS_VALUE_TEMPLATE, CONF_KEEPALIVE, CONF_LAST_RESET_VALUE_TEMPLATE, + CONF_MAX_KELVIN, + CONF_MIN_KELVIN, + CONF_ON_COMMAND_TYPE, CONF_OPTIONS, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, + CONF_RED_TEMPLATE, CONF_RETAIN, + CONF_RGB_COMMAND_TEMPLATE, + CONF_RGB_COMMAND_TOPIC, + CONF_RGB_STATE_TOPIC, + CONF_RGB_VALUE_TEMPLATE, + CONF_RGBW_COMMAND_TEMPLATE, + CONF_RGBW_COMMAND_TOPIC, + CONF_RGBW_STATE_TOPIC, + CONF_RGBW_VALUE_TEMPLATE, + CONF_RGBWW_COMMAND_TEMPLATE, + CONF_RGBWW_COMMAND_TOPIC, + CONF_RGBWW_STATE_TOPIC, + CONF_RGBWW_VALUE_TEMPLATE, + CONF_SCHEMA, CONF_STATE_TOPIC, + CONF_STATE_VALUE_TEMPLATE, CONF_SUGGESTED_DISPLAY_PRECISION, + CONF_SUPPORTED_COLOR_MODES, CONF_TLS_INSECURE, + CONF_TRANSITION, CONF_TRANSPORT, + CONF_WHITE_COMMAND_TOPIC, + CONF_WHITE_SCALE, CONF_WILL_MESSAGE, CONF_WS_HEADERS, CONF_WS_PATH, + CONF_XY_COMMAND_TEMPLATE, + CONF_XY_COMMAND_TOPIC, + CONF_XY_STATE_TOPIC, + CONF_XY_VALUE_TEMPLATE, CONFIG_ENTRY_MINOR_VERSION, CONFIG_ENTRY_VERSION, DEFAULT_BIRTH, DEFAULT_DISCOVERY, DEFAULT_ENCODING, DEFAULT_KEEPALIVE, + DEFAULT_ON_COMMAND_TYPE, DEFAULT_PAYLOAD_AVAILABLE, DEFAULT_PAYLOAD_NOT_AVAILABLE, + DEFAULT_PAYLOAD_OFF, + DEFAULT_PAYLOAD_ON, DEFAULT_PORT, DEFAULT_PREFIX, DEFAULT_PROTOCOL, @@ -144,6 +215,7 @@ from .const import ( SUPPORTED_PROTOCOLS, TRANSPORT_TCP, TRANSPORT_WEBSOCKETS, + VALUES_ON_COMMAND_TYPE, Platform, ) from .models import MqttAvailabilityData, MqttDeviceData, MqttSubentryData @@ -233,7 +305,7 @@ KEY_UPLOAD_SELECTOR = FileSelector( ) # Subentry selectors -SUBENTRY_PLATFORMS = [Platform.NOTIFY, Platform.SENSOR, Platform.SWITCH] +SUBENTRY_PLATFORMS = [Platform.LIGHT, Platform.NOTIFY, Platform.SENSOR, Platform.SWITCH] SUBENTRY_PLATFORM_SELECTOR = SelectSelector( SelectSelectorConfig( options=[platform.value for platform in SUBENTRY_PLATFORMS], @@ -295,6 +367,54 @@ SWITCH_DEVICE_CLASS_SELECTOR = SelectSelector( ) ) +# Light specific selectors +LIGHT_SCHEMA_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=["basic", "json", "template"], + translation_key="light_schema", + ) +) +KELVIN_SELECTOR = NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + min=1000, + max=10000, + step="any", + unit_of_measurement="K", + ) +) +SCALE_SELECTOR = NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + min=1, + max=255, + step=1, + ) +) +FLASH_TIME_SELECTOR = NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + min=1, + ) +) +ON_COMMAND_TYPE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=VALUES_ON_COMMAND_TYPE, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_ON_COMMAND_TYPE, + sort=True, + ) +) +SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[platform.value for platform in VALID_COLOR_MODES], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_SUPPORTED_COLOR_MODES, + multiple=True, + sort=True, + ) +) + @callback def validate_sensor_platform_config( @@ -337,7 +457,7 @@ def validate_sensor_platform_config( return errors -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class PlatformField: """Stores a platform config field schema, required flag and validator.""" @@ -345,7 +465,8 @@ class PlatformField: required: bool validator: Callable[..., Any] error: str | None = None - default: str | int | vol.Undefined = vol.UNDEFINED + default: str | int | bool | None | vol.Undefined = vol.UNDEFINED + is_schema_default: bool = False exclude_from_reconfig: bool = False conditions: tuple[dict[str, Any], ...] | None = None custom_filtering: bool = False @@ -370,102 +491,683 @@ def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector: ) +@callback +def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]: + """Validate MQTT light configuration.""" + errors: dict[str, Any] = {} + if user_data.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) >= user_data.get( + CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN + ): + errors["advanced_settings"] = "max_below_min_kelvin" + return errors + + COMMON_ENTITY_FIELDS = { CONF_PLATFORM: PlatformField( - SUBENTRY_PLATFORM_SELECTOR, True, str, exclude_from_reconfig=True + selector=SUBENTRY_PLATFORM_SELECTOR, + required=True, + validator=str, + exclude_from_reconfig=True, + ), + CONF_NAME: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=str, + exclude_from_reconfig=True, + default=None, + ), + CONF_ENTITY_PICTURE: PlatformField( + selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url" ), - CONF_NAME: PlatformField(TEXT_SELECTOR, False, str, exclude_from_reconfig=True), - CONF_ENTITY_PICTURE: PlatformField(TEXT_SELECTOR, False, cv.url, "invalid_url"), } PLATFORM_ENTITY_FIELDS = { Platform.NOTIFY.value: {}, Platform.SENSOR.value: { - CONF_DEVICE_CLASS: PlatformField(SENSOR_DEVICE_CLASS_SELECTOR, False, str), - CONF_STATE_CLASS: PlatformField(SENSOR_STATE_CLASS_SELECTOR, False, str), + CONF_DEVICE_CLASS: PlatformField( + selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False, validator=str + ), + CONF_STATE_CLASS: PlatformField( + selector=SENSOR_STATE_CLASS_SELECTOR, required=False, validator=str + ), CONF_UNIT_OF_MEASUREMENT: PlatformField( - unit_of_measurement_selector, False, str, custom_filtering=True + selector=unit_of_measurement_selector, + required=False, + validator=str, + custom_filtering=True, ), CONF_SUGGESTED_DISPLAY_PRECISION: PlatformField( - SUGGESTED_DISPLAY_PRECISION_SELECTOR, - False, - cv.positive_int, + selector=SUGGESTED_DISPLAY_PRECISION_SELECTOR, + required=False, + validator=cv.positive_int, section="advanced_settings", ), CONF_OPTIONS: PlatformField( - OPTIONS_SELECTOR, - False, - cv.ensure_list, + selector=OPTIONS_SELECTOR, + required=False, + validator=cv.ensure_list, conditions=({"device_class": "enum"},), ), }, Platform.SWITCH.value: { - CONF_DEVICE_CLASS: PlatformField(SWITCH_DEVICE_CLASS_SELECTOR, False, str), + CONF_DEVICE_CLASS: PlatformField( + selector=SWITCH_DEVICE_CLASS_SELECTOR, required=False, validator=str + ), + }, + Platform.LIGHT.value: { + CONF_SCHEMA: PlatformField( + selector=LIGHT_SCHEMA_SELECTOR, + required=True, + validator=str, + default="basic", + exclude_from_reconfig=True, + ), + CONF_COLOR_TEMP_KELVIN: PlatformField( + selector=BOOLEAN_SELECTOR, + required=True, + validator=bool, + default=True, + is_schema_default=True, + ), }, } PLATFORM_MQTT_FIELDS = { Platform.NOTIFY.value: { CONF_COMMAND_TOPIC: PlatformField( - TEXT_SELECTOR, True, valid_publish_topic, "invalid_publish_topic" + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", ), CONF_COMMAND_TEMPLATE: PlatformField( - TEMPLATE_SELECTOR, False, cv.template, "invalid_template" + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + ), + CONF_RETAIN: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool ), - CONF_RETAIN: PlatformField(BOOLEAN_SELECTOR, False, bool), }, Platform.SENSOR.value: { CONF_STATE_TOPIC: PlatformField( - TEXT_SELECTOR, True, valid_subscribe_topic, "invalid_subscribe_topic" + selector=TEXT_SELECTOR, + required=True, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", ), CONF_VALUE_TEMPLATE: PlatformField( - TEMPLATE_SELECTOR, False, cv.template, "invalid_template" + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", ), CONF_LAST_RESET_VALUE_TEMPLATE: PlatformField( - TEMPLATE_SELECTOR, - False, - cv.template, - "invalid_template", + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", conditions=({CONF_STATE_CLASS: "total"},), ), CONF_EXPIRE_AFTER: PlatformField( - EXPIRE_AFTER_SELECTOR, False, cv.positive_int, section="advanced_settings" + selector=EXPIRE_AFTER_SELECTOR, + required=False, + validator=cv.positive_int, + section="advanced_settings", ), }, Platform.SWITCH.value: { CONF_COMMAND_TOPIC: PlatformField( - TEXT_SELECTOR, True, valid_publish_topic, "invalid_publish_topic" + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", ), CONF_COMMAND_TEMPLATE: PlatformField( - TEMPLATE_SELECTOR, False, cv.template, "invalid_template" + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", ), CONF_STATE_TOPIC: PlatformField( - TEXT_SELECTOR, False, valid_subscribe_topic, "invalid_subscribe_topic" + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", ), CONF_VALUE_TEMPLATE: PlatformField( - TEMPLATE_SELECTOR, False, cv.template, "invalid_template" + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + ), + CONF_RETAIN: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool + ), + CONF_OPTIMISTIC: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool + ), + }, + Platform.LIGHT.value: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_COMMAND_ON_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=True, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_COMMAND_OFF_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=True, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_ON_COMMAND_TYPE: PlatformField( + selector=ON_COMMAND_TYPE_SELECTOR, + required=False, + validator=str, + default=DEFAULT_ON_COMMAND_TYPE, + conditions=({CONF_SCHEMA: "basic"},), + ), + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_STATE_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + ), + CONF_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_SUPPORTED_COLOR_MODES: PlatformField( + selector=SUPPORTED_COLOR_MODES_SELECTOR, + required=False, + validator=valid_supported_color_modes, + error="invalid_supported_color_modes", + conditions=({CONF_SCHEMA: "json"},), + ), + CONF_OPTIMISTIC: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool + ), + CONF_RETAIN: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + validator=bool, + conditions=({CONF_SCHEMA: "basic"},), + ), + CONF_BRIGHTNESS: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + validator=bool, + conditions=({CONF_SCHEMA: "json"},), + section="light_brightness_settings", + ), + CONF_BRIGHTNESS_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_brightness_settings", + ), + CONF_BRIGHTNESS_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_brightness_settings", + ), + CONF_BRIGHTNESS_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_brightness_settings", + ), + CONF_PAYLOAD_OFF: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=str, + default=DEFAULT_PAYLOAD_OFF, + conditions=({CONF_SCHEMA: "basic"},), + ), + CONF_PAYLOAD_ON: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=str, + default=DEFAULT_PAYLOAD_ON, + conditions=({CONF_SCHEMA: "basic"},), + ), + CONF_BRIGHTNESS_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_brightness_settings", + ), + CONF_BRIGHTNESS_SCALE: PlatformField( + selector=SCALE_SELECTOR, + required=False, + validator=cv.positive_int, + default=255, + conditions=( + {CONF_SCHEMA: "basic"}, + {CONF_SCHEMA: "json"}, + ), + section="light_brightness_settings", + ), + CONF_COLOR_MODE_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_mode_settings", + ), + CONF_COLOR_MODE_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_mode_settings", + ), + CONF_COLOR_TEMP_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_temp_settings", + ), + CONF_COLOR_TEMP_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_temp_settings", + ), + CONF_COLOR_TEMP_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_temp_settings", + ), + CONF_COLOR_TEMP_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_temp_settings", + ), + CONF_BRIGHTNESS_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_RED_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_GREEN_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_BLUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_COLOR_TEMP_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_HS_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_hs_settings", + ), + CONF_HS_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_hs_settings", + ), + CONF_HS_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_hs_settings", + ), + CONF_HS_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_hs_settings", + ), + CONF_RGB_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgb_settings", + ), + CONF_RGB_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgb_settings", + ), + CONF_RGB_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgb_settings", + ), + CONF_RGB_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgb_settings", + ), + CONF_RGBW_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbw_settings", + ), + CONF_RGBW_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbw_settings", + ), + CONF_RGBW_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbw_settings", + ), + CONF_RGBW_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbw_settings", + ), + CONF_RGBWW_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbww_settings", + ), + CONF_RGBWW_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbww_settings", + ), + CONF_RGBWW_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbww_settings", + ), + CONF_RGBWW_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbww_settings", + ), + CONF_XY_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_xy_settings", + ), + CONF_XY_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_xy_settings", + ), + CONF_XY_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_xy_settings", + ), + CONF_XY_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_xy_settings", + ), + CONF_WHITE_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_white_settings", + ), + CONF_WHITE_SCALE: PlatformField( + selector=SCALE_SELECTOR, + required=False, + validator=cv.positive_int, + default=255, + conditions=( + {CONF_SCHEMA: "basic"}, + {CONF_SCHEMA: "json"}, + ), + section="light_white_settings", + ), + CONF_EFFECT: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + validator=bool, + conditions=({CONF_SCHEMA: "json"},), + section="light_effect_settings", + ), + CONF_EFFECT_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_effect_settings", + ), + CONF_EFFECT_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_effect_settings", + ), + CONF_EFFECT_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_effect_settings", + ), + CONF_EFFECT_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + section="light_effect_settings", + ), + CONF_EFFECT_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_effect_settings", + ), + CONF_EFFECT_LIST: PlatformField( + selector=OPTIONS_SELECTOR, + required=False, + validator=cv.ensure_list, + section="light_effect_settings", + ), + CONF_FLASH: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + default=False, + validator=cv.boolean, + conditions=({CONF_SCHEMA: "json"},), + section="advanced_settings", + ), + CONF_FLASH_TIME_SHORT: PlatformField( + selector=FLASH_TIME_SELECTOR, + required=False, + validator=cv.positive_int, + default=2, + conditions=({CONF_SCHEMA: "json"},), + section="advanced_settings", + ), + CONF_FLASH_TIME_LONG: PlatformField( + selector=FLASH_TIME_SELECTOR, + required=False, + validator=cv.positive_int, + default=10, + conditions=({CONF_SCHEMA: "json"},), + section="advanced_settings", + ), + CONF_TRANSITION: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + default=False, + validator=cv.boolean, + conditions=({CONF_SCHEMA: "json"},), + section="advanced_settings", + ), + CONF_MAX_KELVIN: PlatformField( + selector=KELVIN_SELECTOR, + required=False, + validator=cv.positive_int, + default=DEFAULT_MAX_KELVIN, + section="advanced_settings", + ), + CONF_MIN_KELVIN: PlatformField( + selector=KELVIN_SELECTOR, + required=False, + validator=cv.positive_int, + default=DEFAULT_MIN_KELVIN, + section="advanced_settings", ), - CONF_RETAIN: PlatformField(BOOLEAN_SELECTOR, False, bool), - CONF_OPTIMISTIC: PlatformField(BOOLEAN_SELECTOR, False, bool), }, } ENTITY_CONFIG_VALIDATOR: dict[ str, Callable[[dict[str, Any]], dict[str, str]] | None, ] = { + Platform.LIGHT.value: validate_light_platform_config, Platform.NOTIFY.value: None, Platform.SENSOR.value: validate_sensor_platform_config, Platform.SWITCH.value: None, } MQTT_DEVICE_PLATFORM_FIELDS = { - ATTR_NAME: PlatformField(TEXT_SELECTOR, False, str), - ATTR_SW_VERSION: PlatformField(TEXT_SELECTOR, False, str), - ATTR_HW_VERSION: PlatformField(TEXT_SELECTOR, False, str), - ATTR_MODEL: PlatformField(TEXT_SELECTOR, False, str), - ATTR_MODEL_ID: PlatformField(TEXT_SELECTOR, False, str), - ATTR_CONFIGURATION_URL: PlatformField(TEXT_SELECTOR, False, cv.url, "invalid_url"), + ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=True, validator=str), + ATTR_SW_VERSION: PlatformField( + selector=TEXT_SELECTOR, required=False, validator=str + ), + ATTR_HW_VERSION: PlatformField( + selector=TEXT_SELECTOR, required=False, validator=str + ), + ATTR_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False, validator=str), + ATTR_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False, validator=str), + ATTR_CONFIGURATION_URL: PlatformField( + selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url" + ), CONF_QOS: PlatformField( - QOS_SELECTOR, False, int, default=DEFAULT_QOS, section="mqtt_settings" + selector=QOS_SELECTOR, + required=False, + validator=int, + default=DEFAULT_QOS, + section="mqtt_settings", ), } @@ -514,7 +1216,7 @@ def validate_field( return try: validator(user_input[field]) - except (ValueError, vol.Invalid): + except (ValueError, vol.Error, vol.Invalid): errors[field] = error @@ -572,8 +1274,11 @@ def validate_user_input( validator = data_schema_fields[field].validator try: validator(value) - except (ValueError, vol.Invalid): - errors[field] = data_schema_fields[field].error or "invalid_input" + except (ValueError, vol.Error, vol.Invalid): + data_schema_field = data_schema_fields[field] + errors[data_schema_field.section or field] = ( + data_schema_field.error or "invalid_input" + ) if config_validator is not None: if TYPE_CHECKING: @@ -610,7 +1315,9 @@ def data_schema_from_fields( component_data_with_user_input |= user_input sections: dict[str | None, None] = { - field_details.section: None for field_details in data_schema_fields.values() + field_details.section: None + for field_details in data_schema_fields.values() + if not field_details.is_schema_default } data_schema: dict[Any, Any] = {} all_data_element_options: set[Any] = set() @@ -620,12 +1327,16 @@ def data_schema_from_fields( vol.Required(field_name, default=field_details.default) if field_details.required else vol.Optional( - field_name, default=field_details.default + field_name, + default=field_details.default + if field_details.default is not None + else vol.UNDEFINED, ): field_details.selector(component_data_with_user_input) # type: ignore[operator] if field_details.custom_filtering else field_details.selector for field_name, field_details in data_schema_fields.items() - if field_details.section == schema_section + if not field_details.is_schema_default + and field_details.section == schema_section and (not field_details.exclude_from_reconfig or not reconfig) and _check_conditions(field_details, component_data_with_user_input) } @@ -637,6 +1348,8 @@ def data_schema_from_fields( if field_details.section == schema_section and field_details.exclude_from_reconfig } + if not data_element_options: + continue if schema_section is None: data_schema.update(data_schema_element) continue @@ -665,6 +1378,23 @@ def data_schema_from_fields( return vol.Schema(data_schema) +@callback +def subentry_schema_default_data_from_fields( + data_schema_fields: dict[str, PlatformField], + component_data: dict[str, Any], +) -> dict[str, Any]: + """Generate custom data schema from platform fields or device data.""" + return { + key: field.default + for key, field in data_schema_fields.items() + if _check_conditions(field, component_data) + and ( + field.is_schema_default + or (field.default is not vol.UNDEFINED and key not in component_data) + ) + } + + class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -1481,6 +2211,19 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): last_step=False, ) + @callback + def _async_update_component_data_defaults(self) -> None: + """Update component data defaults.""" + for component_data in self._subentry_data["components"].values(): + platform = component_data[CONF_PLATFORM] + subentry_default_data = subentry_schema_default_data_from_fields( + COMMON_ENTITY_FIELDS + | PLATFORM_ENTITY_FIELDS[platform] + | PLATFORM_MQTT_FIELDS[platform], + component_data, + ) + component_data.update(subentry_default_data) + @callback def _async_create_subentry( self, user_input: dict[str, Any] | None = None @@ -1497,6 +2240,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): else: full_entity_name = device_name + self._async_update_component_data_defaults() return self.async_create_entry( data=self._subentry_data, title=self._subentry_data[CONF_DEVICE][CONF_NAME], @@ -1561,6 +2305,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): if len(self._subentry_data["components"]) > 1: menu_options.append("delete_entity") menu_options.extend(["device", "availability"]) + self._async_update_component_data_defaults() if self._subentry_data != self._get_reconfigure_subentry().data: menu_options.append("save_changes") return self.async_show_menu( diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index b2fcd492435..18107c5c939 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -87,6 +87,7 @@ CONF_EFFECT_TEMPLATE = "effect_template" CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template" CONF_ENTITY_PICTURE = "entity_picture" CONF_EXPIRE_AFTER = "expire_after" +CONF_FLASH = "flash" CONF_FLASH_TIME_LONG = "flash_time_long" CONF_FLASH_TIME_SHORT = "flash_time_short" CONF_GREEN_TEMPLATE = "green_template" @@ -139,6 +140,7 @@ CONF_TEMP_STATE_TOPIC = "temperature_state_topic" CONF_TEMP_INITIAL = "initial" CONF_TEMP_MAX = "max_temp" CONF_TEMP_MIN = "min_temp" +CONF_TRANSITION = "transition" CONF_XY_COMMAND_TEMPLATE = "xy_command_template" CONF_XY_COMMAND_TOPIC = "xy_command_topic" CONF_XY_STATE_TOPIC = "xy_state_topic" @@ -194,6 +196,8 @@ DEFAULT_POSITION_OPEN = 100 DEFAULT_RETAIN = False DEFAULT_WHITE_SCALE = 255 +VALUES_ON_COMMAND_TYPE = ["first", "last", "brightness"] + PROTOCOL_31 = "3.1" PROTOCOL_311 = "3.1.1" PROTOCOL_5 = "5" diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 4017245cf51..141e0478f2f 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -28,8 +28,13 @@ from homeassistant.helpers.typing import ConfigType, VolSchemaType from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_PAYLOAD_RESET, CONF_STATE_TOPIC -from .entity import CONF_JSON_ATTRS_TOPIC, MqttEntity, async_setup_entity_entry_helper +from .const import ( + CONF_JSON_ATTRS_TEMPLATE, + CONF_JSON_ATTRS_TOPIC, + CONF_PAYLOAD_RESET, + CONF_STATE_TOPIC, +) +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_subscribe_topic @@ -111,6 +116,7 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): self._value_template = MqttValueTemplate( config.get(CONF_VALUE_TEMPLATE), entity=self ).async_render_with_possible_json_value + self._attr_source_type = self._config[CONF_SOURCE_TYPE] @callback def _tracker_message_received(self, msg: ReceiveMessage) -> None: @@ -124,72 +130,82 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): ) return if payload == self._config[CONF_PAYLOAD_HOME]: - self._location_name = STATE_HOME + self._attr_location_name = STATE_HOME elif payload == self._config[CONF_PAYLOAD_NOT_HOME]: - self._location_name = STATE_NOT_HOME + self._attr_location_name = STATE_NOT_HOME elif payload == self._config[CONF_PAYLOAD_RESET]: - self._location_name = None + self._attr_location_name = None else: if TYPE_CHECKING: assert isinstance(msg.payload, str) - self._location_name = msg.payload + self._attr_location_name = msg.payload @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" self.add_subscription( - CONF_STATE_TOPIC, self._tracker_message_received, {"_location_name"} + CONF_STATE_TOPIC, self._tracker_message_received, {"_attr_location_name"} ) - @property - def force_update(self) -> bool: - """Do not force updates if the state is the same.""" - return False - async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" subscription.async_subscribe_topics_internal(self.hass, self._sub_state) - @property - def latitude(self) -> float | None: - """Return latitude if provided in extra_state_attributes or None.""" + @callback + def _process_update_extra_state_attributes( + self, extra_state_attributes: dict[str, Any] + ) -> None: + """Extract the location from the extra state attributes.""" if ( - self.extra_state_attributes is not None - and ATTR_LATITUDE in self.extra_state_attributes + ATTR_LATITUDE in extra_state_attributes + or ATTR_LONGITUDE in extra_state_attributes ): - latitude: float = self.extra_state_attributes[ATTR_LATITUDE] - return latitude - return None + latitude: float | None + longitude: float | None + gps_accuracy: float + # Reset manually set location to allow automatic zone detection + self._attr_location_name = None + if isinstance( + latitude := extra_state_attributes.get(ATTR_LATITUDE), (int, float) + ) and isinstance( + longitude := extra_state_attributes.get(ATTR_LONGITUDE), (int, float) + ): + self._attr_latitude = latitude + self._attr_longitude = longitude + else: + # Invalid or incomplete coordinates, reset location + self._attr_latitude = None + self._attr_longitude = None + _LOGGER.warning( + "Extra state attributes received at % and template %s " + "contain invalid or incomplete location info. Got %s", + self._config.get(CONF_JSON_ATTRS_TEMPLATE), + self._config.get(CONF_JSON_ATTRS_TOPIC), + extra_state_attributes, + ) - @property - def location_accuracy(self) -> int: - """Return location accuracy if provided in extra_state_attributes or None.""" - if ( - self.extra_state_attributes is not None - and ATTR_GPS_ACCURACY in self.extra_state_attributes - ): - accuracy: int = self.extra_state_attributes[ATTR_GPS_ACCURACY] - return accuracy - return 0 + if ATTR_GPS_ACCURACY in extra_state_attributes: + if isinstance( + gps_accuracy := extra_state_attributes[ATTR_GPS_ACCURACY], + (int, float), + ): + self._attr_location_accuracy = gps_accuracy + else: + _LOGGER.warning( + "Extra state attributes received at % and template %s " + "contain invalid GPS accuracy setting, " + "gps_accuracy was set to 0 as the default. Got %s", + self._config.get(CONF_JSON_ATTRS_TEMPLATE), + self._config.get(CONF_JSON_ATTRS_TOPIC), + extra_state_attributes, + ) + self._attr_location_accuracy = 0 - @property - def longitude(self) -> float | None: - """Return longitude if provided in extra_state_attributes or None.""" - if ( - self.extra_state_attributes is not None - and ATTR_LONGITUDE in self.extra_state_attributes - ): - longitude: float = self.extra_state_attributes[ATTR_LONGITUDE] - return longitude - return None + else: + self._attr_location_accuracy = 0 - @property - def location_name(self) -> str | None: - """Return a location name for the current location of the device.""" - return self._location_name - - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - source_type: SourceType = self._config[CONF_SOURCE_TYPE] - return source_type + self._attr_extra_state_attributes = { + attribute: value + for attribute, value in extra_state_attributes.items() + if attribute not in {ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE} + } diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 8446f9041c9..1202f04ed42 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -399,6 +399,10 @@ class MqttAttributesMixin(Entity): _attributes_extra_blocked: frozenset[str] = frozenset() _attr_tpl: Callable[[ReceivePayloadType], ReceivePayloadType] | None = None + _message_callback: Callable[ + [MessageCallbackType, set[str] | None, ReceiveMessage], None + ] + _process_update_extra_state_attributes: Callable[[dict[str, Any]], None] def __init__(self, config: ConfigType) -> None: """Initialize the JSON attributes mixin.""" @@ -433,9 +437,15 @@ class MqttAttributesMixin(Entity): CONF_JSON_ATTRS_TOPIC: { "topic": self._attributes_config.get(CONF_JSON_ATTRS_TOPIC), "msg_callback": partial( - self._message_callback, # type: ignore[attr-defined] + self._message_callback, self._attributes_message_received, - {"_attr_extra_state_attributes"}, + { + "_attr_extra_state_attributes", + "_attr_gps_accuracy", + "_attr_latitude", + "_attr_location_name", + "_attr_longitude", + }, ), "entity_id": self.entity_id, "qos": self._attributes_config.get(CONF_QOS), @@ -474,7 +484,11 @@ class MqttAttributesMixin(Entity): if k not in MQTT_ATTRIBUTES_BLOCKED and k not in self._attributes_extra_blocked } - self._attr_extra_state_attributes = filtered_dict + if hasattr(self, "_process_update_extra_state_attributes"): + self._process_update_extra_state_attributes(filtered_dict) + else: + self._attr_extra_state_attributes = filtered_dict + else: _LOGGER.warning("JSON result was not a dictionary") @@ -482,6 +496,10 @@ class MqttAttributesMixin(Entity): class MqttAvailabilityMixin(Entity): """Mixin used for platforms that report availability.""" + _message_callback: Callable[ + [MessageCallbackType, set[str] | None, ReceiveMessage], None + ] + def __init__(self, config: ConfigType) -> None: """Initialize the availability mixin.""" self._availability_sub_state: dict[str, EntitySubscription] = {} @@ -547,7 +565,7 @@ class MqttAvailabilityMixin(Entity): f"availability_{topic}": { "topic": topic, "msg_callback": partial( - self._message_callback, # type: ignore[attr-defined] + self._message_callback, self._availability_message_received, {"available"}, ), diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index a950aced665..61a55d64049 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -104,6 +104,7 @@ from ..const import ( DEFAULT_PAYLOAD_ON, DEFAULT_WHITE_SCALE, PAYLOAD_NONE, + VALUES_ON_COMMAND_TYPE, ) from ..entity import MqttEntity from ..models import ( @@ -143,8 +144,6 @@ MQTT_LIGHT_ATTRIBUTES_BLOCKED = frozenset( } ) -VALUES_ON_COMMAND_TYPE = ["first", "last", "brightness"] - COMMAND_TEMPLATE_KEYS = [ CONF_BRIGHTNESS_COMMAND_TEMPLATE, CONF_COLOR_TEMP_COMMAND_TEMPLATE, diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index a1f86278cf0..fc76d4bcf6c 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -59,6 +59,7 @@ from ..const import ( CONF_COLOR_TEMP_KELVIN, CONF_COMMAND_TOPIC, CONF_EFFECT_LIST, + CONF_FLASH, CONF_FLASH_TIME_LONG, CONF_FLASH_TIME_SHORT, CONF_MAX_KELVIN, @@ -69,6 +70,7 @@ from ..const import ( CONF_RETAIN, CONF_STATE_TOPIC, CONF_SUPPORTED_COLOR_MODES, + CONF_TRANSITION, DEFAULT_BRIGHTNESS, DEFAULT_BRIGHTNESS_SCALE, DEFAULT_EFFECT, @@ -93,6 +95,9 @@ DOMAIN = "mqtt_json" DEFAULT_NAME = "MQTT JSON Light" +DEFAULT_FLASH = True +DEFAULT_TRANSITION = True + _PLATFORM_SCHEMA_BASE = ( MQTT_RW_SCHEMA.extend( { @@ -103,6 +108,7 @@ _PLATFORM_SCHEMA_BASE = ( 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(CONF_FLASH, default=DEFAULT_FLASH): cv.boolean, vol.Optional( CONF_FLASH_TIME_LONG, default=DEFAULT_FLASH_TIME_LONG ): cv.positive_int, @@ -125,6 +131,7 @@ _PLATFORM_SCHEMA_BASE = ( vol.Unique(), valid_supported_color_modes, ), + vol.Optional(CONF_TRANSITION, default=DEFAULT_TRANSITION): cv.boolean, vol.Optional(CONF_WHITE_SCALE, default=DEFAULT_WHITE_SCALE): vol.All( vol.Coerce(int), vol.Range(min=1) ), @@ -199,12 +206,13 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): for key in (CONF_FLASH_TIME_SHORT, CONF_FLASH_TIME_LONG) } - self._attr_supported_features = ( - LightEntityFeature.TRANSITION | LightEntityFeature.FLASH - ) self._attr_supported_features |= ( config[CONF_EFFECT] and LightEntityFeature.EFFECT ) + self._attr_supported_features |= config[CONF_FLASH] and LightEntityFeature.FLASH + self._attr_supported_features |= ( + config[CONF_TRANSITION] and LightEntityFeature.TRANSITION + ) if supported_color_modes := self._config.get(CONF_SUPPORTED_COLOR_MODES): self._attr_supported_color_modes = supported_color_modes if self.supported_color_modes and len(self.supported_color_modes) == 1: diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index cedf120def1..7339f3869a1 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1,7 +1,7 @@ { "issues": { "invalid_platform_config": { - "title": "Invalid config found for mqtt {domain} item", + "title": "Invalid config found for MQTT {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." }, "invalid_unit_of_measurement": { @@ -43,8 +43,8 @@ "data_description": { "broker": "The hostname or IP address of your MQTT broker.", "port": "The port your MQTT broker listens to. For example 1883.", - "username": "The username to login to your MQTT broker.", - "password": "The password to login to your MQTT broker.", + "username": "The username to log in to your MQTT broker.", + "password": "The password to log in to your MQTT broker.", "advanced_options": "Enable and select **Next** to set advanced options.", "certificate": "The custom CA certificate file to validate your MQTT brokers certificate.", "client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.", @@ -68,7 +68,7 @@ "title": "Starting add-on" }, "hassio_confirm": { - "title": "MQTT Broker via Home Assistant add-on", + "title": "MQTT broker via Home Assistant add-on", "description": "Do you want to configure Home Assistant to connect to the MQTT broker provided by the add-on {addon}?" }, "reauth_confirm": { @@ -153,7 +153,7 @@ }, "sections": { "mqtt_settings": { - "name": "MQTT Settings", + "name": "MQTT settings", "data": { "qos": "QoS" }, @@ -214,15 +214,19 @@ "description": "Please configure specific details for {platform} entity \"{entity}\":", "data": { "device_class": "Device class", + "options": "Add option", + "schema": "Schema", "state_class": "State class", - "unit_of_measurement": "Unit of measurement", - "options": "Add option" + "suggested_display_precision": "Suggested display precision", + "unit_of_measurement": "Unit of measurement" }, "data_description": { "device_class": "The Device class of the {platform} entity. [Learn more.]({url}#device_class)", + "options": "Options for allowed sensor state values. The sensor’s Device class must be set to Enumeration. The 'Options' setting cannot be used together with State class or Unit of measurement.", + "schema": "The schema to use. [Learn more.]({url}#comparison-of-light-mqtt-schemas)", "state_class": "The [State class](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes) of the sensor. [Learn more.]({url}#state_class)", - "unit_of_measurement": "Defines the unit of measurement of the sensor, if any.", - "options": "Options for allowed sensor state values. The sensor’s Device class must be set to Enumeration. The 'Options' setting cannot be used together with State class or Unit of measurement." + "suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)", + "unit_of_measurement": "Defines the unit of measurement of the sensor, if any." }, "sections": { "advanced_settings": { @@ -240,33 +244,222 @@ "title": "Configure MQTT device \"{mqtt_device}\"", "description": "Please configure MQTT specific details for {platform} entity \"{entity}\":", "data": { - "command_topic": "Command topic", + "on_command_type": "ON command type", + "blue_template": "Blue template", + "brightness_template": "Brightness template", "command_template": "Command template", - "state_topic": "State topic", - "value_template": "Value template", - "last_reset_value_template": "Last reset value template", + "command_topic": "Command topic", + "command_off_template": "Command \"off\" template", + "command_on_template": "Command \"on\" template", + "color_temp_template": "Color temperature template", "force_update": "Force update", + "green_template": "Green template", + "last_reset_value_template": "Last reset value template", "optimistic": "Optimistic", - "retain": "Retain" + "payload_off": "Payload \"off\"", + "payload_on": "Payload \"on\"", + "qos": "QoS", + "red_template": "Red template", + "retain": "Retain", + "state_template": "State template", + "state_topic": "State topic", + "state_value_template": "State value template", + "supported_color_modes": "Supported color modes", + "value_template": "Value template" }, "data_description": { - "command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)", + "blue_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract blue color from the state payload value. Expected result of the template is an integer from 0-255 range.", + "brightness_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract brightness from the state payload value. Expected result of the template is an integer from 0-255 range.", + "command_off_template": "The [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) for \"off\" state changes. Available variables are: `state` and `transition`.", + "command_on_template": "The [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) for \"on\" state changes. Available variables: `state`, `brightness`, `color_temp`, `red`, `green`, `blue`, `hue`, `sat`, `flash`, `transition` and `effect`. Values `red`, `green`, `blue` and `brightness` are provided as integers from range 0-255. Value of `hue` is provided as float from range 0-360. Value of `sat` is provided as float from range 0-100. Value of `color_temp` is provided as integer representing Kelvin units.", "command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic.", - "state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)", - "value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the {platform} entity value.", + "command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)", + "color_temp_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract color temperature in Kelvin from the state payload value. Expected result of the template is an integer.", + "green_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract green color from the state payload value. Expected result of the template is an integer from 0-255 range.", "last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)", "force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)", + "on_command_type": "Defines when the payload \"on\" is sent. Using \"Last\" (the default) will send any style (brightness, color, etc) topics first and then a payload \"on\" to the command topic. Using \"First\" will send the payload \"on\" and then any style topics. Using \"Brightness\" will only send brightness commands instead of the payload \"on\" to turn the light on.", "optimistic": "Flag that defines if the {platform} entity works in optimistic mode. [Learn more.]({url}#optimistic)", - "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker." + "payload_off": "The payload that represents the off state.", + "payload_on": "The payload that represents the on state.", + "qos": "The QoS value a {platform} entity should use.", + "red_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract red color from the state payload value. Expected result of the template is an integer from 0-255 range.", + "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.", + "state_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract state from the state payload value.", + "state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)", + "supported_color_modes": "A list of color modes supported by the list. Possible color modes are On/Off, Brightness, Color temperature, HS, XY, RGB, RGBW, RGBWW, White. Note that if On/Off or Brightness are used, that must be the only value in the list. [Learn more.]({url}#supported_color_modes)", + "value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the {platform} entity value. [Learn more.]({url}#value_template)" }, "sections": { "advanced_settings": { "name": "Advanced settings", "data": { - "expire_after": "Expire after" + "expire_after": "Expire after", + "flash": "Flash support", + "flash_time_long": "Flash time long", + "flash_time_short": "Flash time short", + "max_kelvin": "Max Kelvin", + "min_kelvin": "Min Kelvin", + "transition": "Transition support" }, "data_description": { - "expire_after": "If set, it defines the number of seconds after the sensor’s state expires, if it’s not updated. After expiry, the sensor’s state becomes unavailable. If not set, the sensor's state never expires. [Learn more.]({url}#expire_after)" + "expire_after": "If set, it defines the number of seconds after the sensor’s state expires, if it’s not updated. After expiry, the sensor’s state becomes unavailable. If not set, the sensor's state never expires. [Learn more.]({url}#expire_after)", + "flash": "Enable the flash feature for this light", + "flash_time_long": "The duration, in seconds, of a \"long\" flash.", + "flash_time_short": "The duration, in seconds, of a \"short\" flash.", + "max_kelvin": "The maximum color temperature in Kelvin.", + "min_kelvin": "The minimum color temperature in Kelvin.", + "transition": "Enable the transition feature for this light" + } + }, + "light_brightness_settings": { + "name": "Brightness settings", + "data": { + "brightness": "Separate brightness", + "brightness_command_template": "Brightness command template", + "brightness_command_topic": "Brightness command topic", + "brightness_scale": "Brightness scale", + "brightness_state_topic": "Brightness state topic", + "brightness_value_template": "Brightness value template" + }, + "data_description": { + "brightness": "Flag that defines if light supports brightness when the RGB, RGBW, or RGBWW color mode is supported.", + "brightness_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the brightness command topic.", + "brightness_command_topic": "The publishing topic that will be used to control the brightness. [Learn more.]({url}#brightness_command_topic)", + "brightness_scale": "Defines the maximum brightness value (i.e., 100%) of the maximum brightness.", + "brightness_state_topic": "The MQTT topic subscribed to receive brightness state values. [Learn more.]({url}#brightness_state_topic)", + "brightness_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the brightness value." + } + }, + "light_color_mode_settings": { + "name": "Color mode settings", + "data": { + "color_mode_state_topic": "Color mode state topic", + "color_mode_value_template": "Color mode value template" + }, + "data_description": { + "color_mode_state_topic": "The MQTT topic subscribed to receive color mode updates. If this is not configured, the color mode will be automatically set according to the last received valid color or color temperature.", + "color_mode_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the color mode value." + } + }, + "light_color_temp_settings": { + "name": "Color temperature settings", + "data": { + "color_temp_command_template": "Color temperature command template", + "color_temp_command_topic": "Color temperature command topic", + "color_temp_state_topic": "Color temperature state topic", + "color_temp_value_template": "Color temperature value template" + }, + "data_description": { + "color_temp_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the color temperature command topic.", + "color_temp_command_topic": "The publishing topic that will be used to control the color temperature. [Learn more.]({url}#color_temp_command_topic)", + "color_temp_state_topic": "The MQTT topic subscribed to receive color temperature state updates. [Learn more.]({url}#color_temp_state_topic)", + "color_temp_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the color temperature value." + } + }, + "light_effect_settings": { + "name": "Effect settings", + "data": { + "effect": "Effect", + "effect_command_template": "Effect command template", + "effect_command_topic": "Effect command topic", + "effect_list": "Effect list", + "effect_state_topic": "Effect state topic", + "effect_template": "Effect template", + "effect_value_template": "Effect value template" + }, + "data_description": { + "effect": "Flag that defines if the light supports effects.", + "effect_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the effect command topic.", + "effect_command_topic": "The publishing topic that will be used to control the light's effect state. [Learn more.]({url}#effect_command_topic)", + "effect_list": "The list of effects the light supports.", + "effect_state_topic": "The MQTT topic subscribed to receive effect state updates. [Learn more.]({url}#effect_state_topic)" + } + }, + "light_hs_settings": { + "name": "HS color mode settings", + "data": { + "hs_command_template": "HS command template", + "hs_command_topic": "HS command topic", + "hs_state_topic": "HS state topic", + "hs_value_template": "HS value template" + }, + "data_description": { + "hs_command_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose message which will be sent to HS command topic. Available variables: `hue` and `sat`.", + "hs_command_topic": "The MQTT topic to publish commands to change the light’s color state in HS format (Hue Saturation). Range for Hue: 0° .. 360°, Range of Saturation: 0..100. Note: Brightness is sent separately in the brightness command topic. [Learn more.]({url}#hs_command_topic)", + "hs_state_topic": "The MQTT topic subscribed to receive color state updates in HS format. The expected payload is the hue and saturation values separated by commas, for example, `359.5,100.0`. Note: Brightness is received separately in the brightness state topic. [Learn more.]({url}#hs_state_topic)", + "hs_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the HS value." + } + }, + "light_rgb_settings": { + "name": "RGB color mode settings", + "data": { + "rgb_command_template": "RGB command template", + "rgb_command_topic": "RGB command topic", + "rgb_state_topic": "RGB state topic", + "rgb_value_template": "RGB value template" + }, + "data_description": { + "rgb_command_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose message which will be sent to RGB command topic. Available variables: `red`, `green` and `blue`.", + "rgb_command_topic": "The MQTT topic to publish commands to change the light’s RGB state. [Learn more.]({url}#rgb_command_topic)", + "rgb_state_topic": "The MQTT topic subscribed to receive RGB state updates. The expected payload is the RGB values separated by commas, for example, `255,0,127`. [Learn more.]({url}#rgb_state_topic)", + "rgb_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the RGB value." + } + }, + "light_rgbw_settings": { + "name": "RGBW color mode settings", + "data": { + "rgbw_command_template": "RGBW command template", + "rgbw_command_topic": "RGBW command topic", + "rgbw_state_topic": "RGBW state topic", + "rgbw_value_template": "RGBW value template" + }, + "data_description": { + "rgbw_command_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose message which will be sent to RGBW command topic. Available variables: `red`, `green`, `blue` and `white`.", + "rgbw_command_topic": "The MQTT topic to publish commands to change the light’s RGBW state. [Learn more.]({url}#rgbw_command_topic)", + "rgbw_state_topic": "The MQTT topic subscribed to receive RGBW state updates. The expected payload is the RGBW values separated by commas, for example, `255,0,127,64`. [Learn more.]({url}#rgbw_state_topic)", + "rgbw_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the RGBW value." + } + }, + "light_rgbww_settings": { + "name": "RGBWW color mode settings", + "data": { + "rgbww_command_template": "RGBWW command template", + "rgbww_command_topic": "RGBWW command topic", + "rgbww_state_topic": "RGBWW state topic", + "rgbww_value_template": "RGBWW value template" + }, + "data_description": { + "rgbww_command_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose message which will be sent to RGBWW command topic. Available variables: `red`, `green`, `blue`, `cold_white` and `warm_white`.", + "rgbww_command_topic": "The MQTT topic to publish commands to change the light’s RGBWW state. [Learn more.]({url}#rgbww_command_topic)", + "rgbww_state_topic": "The MQTT topic subscribed to receive RGBWW state updates. The expected payload is the RGBWW values separated by commas, for example, `255,0,127,64,32`. [Learn more.]({url}#rgbww_state_topic)", + "rgbww_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the RGBWW value." + } + }, + "light_white_settings": { + "name": "White color mode settings", + "data": { + "white_command_topic": "White command topic", + "white_scale": "White scale" + }, + "data_description": { + "white_command_topic": "The MQTT topic to publish commands to change the light to white mode with a given brightness. [Learn more.]({url}#white_command_topic)", + "white_scale": "Defines the maximum white level (i.e., 100%) of the maximum." + } + }, + "light_xy_settings": { + "name": "XY color mode settings", + "data": { + "xy_command_template": "XY command template", + "xy_command_topic": "XY command topic", + "xy_state_topic": "XY state topic", + "xy_value_template": "XY value template" + }, + "data_description": { + "xy_command_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose message which will be sent to XY command topic. Available variables: `x` and `y`.", + "xy_command_topic": "The MQTT topic to publish commands to change the light’s XY state. [Learn more.]({url}#xy_command_topic)", + "xy_state_topic": "The MQTT topic subscribed to receive XY state updates. The expected payload is the X and Y color values separated by commas, for example, `0.675,0.322`. [Learn more.]({url}#xy_state_topic)", + "xy_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the XY value." } } } @@ -282,8 +475,11 @@ "invalid_input": "Invalid value", "invalid_subscribe_topic": "Invalid subscribe topic", "invalid_template": "Invalid template", + "invalid_supported_color_modes": "Invalid supported color modes selection", "invalid_uom": "The unit of measurement \"{unit_of_measurement}\" is not supported by the selected device class, please either remove the device class, select a device class which supports \"{unit_of_measurement}\", or pick a supported unit of measurement from the list", "invalid_url": "Invalid URL", + "last_reset_not_with_state_class_total": "The last reset value template option should be used with state class 'Total' only", + "max_below_min_kelvin": "Max Kelvin value should be greater than min Kelvin value", "options_not_allowed_with_state_class_or_uom": "The 'Options' setting is not allowed when state class or unit of measurement are used", "options_device_class_enum": "The 'Options' setting must be used with the Enumeration device class. If you continue, the existing options will be reset", "options_with_enum_device_class": "Configure options for the enumeration sensor", @@ -378,15 +574,15 @@ "discovery": "Option to enable MQTT automatic discovery.", "discovery_prefix": "The prefix of configuration topics the MQTT integration will subscribe to.", "birth_enable": "When set, Home Assistant will publish an online message to your MQTT broker when MQTT is ready.", - "birth_topic": "The MQTT topic where Home Assistant will publish a `birth` message.", - "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 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.", - "will_retain": "When set, your MQTT broker will retain the `will` message." + "birth_topic": "The MQTT topic where Home Assistant will publish a \"birth\" message.", + "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 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.", + "will_retain": "When set, your MQTT broker will retain the \"will\" message." } } }, @@ -470,17 +666,32 @@ "switch": "[%key:component::switch::title%]" } }, + "light_schema": { + "options": { + "basic": "Default schema", + "json": "JSON", + "template": "Template" + } + }, + "on_command_type": { + "options": { + "brightness": "Brightness", + "first": "First", + "last": "Last" + } + }, "platform": { "options": { - "notify": "Notify", - "sensor": "Sensor", - "switch": "Switch" + "light": "[%key:component::light::title%]", + "notify": "[%key:component::notify::title%]", + "sensor": "[%key:component::sensor::title%]", + "switch": "[%key:component::switch::title%]" } }, "set_ca_cert": { "options": { "off": "[%key:common::state::off%]", - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "custom": "Custom" } }, @@ -490,6 +701,19 @@ "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" } + }, + "supported_color_modes": { + "options": { + "onoff": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::onoff%]", + "brightness": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::brightness%]", + "color_temp": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::color_temp%]", + "hs": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::hs%]", + "xy": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::xy%]", + "rgb": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgb%]", + "rgbw": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgbw%]", + "rgbww": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgbww%]", + "white": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::white%]" + } } }, "services": { diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py index a926e2a0595..11cbbd3f655 100644 --- a/homeassistant/components/music_assistant/media_browser.py +++ b/homeassistant/components/music_assistant/media_browser.py @@ -2,9 +2,15 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +import logging +from typing import TYPE_CHECKING, Any, cast -from music_assistant_models.media_items import MediaItemType +from music_assistant_models.enums import MediaType as MASSMediaType +from music_assistant_models.media_items import ( + BrowseFolder, + MediaItemType, + SearchResults, +) from homeassistant.components import media_source from homeassistant.components.media_player import ( @@ -12,6 +18,9 @@ from homeassistant.components.media_player import ( BrowseMedia, MediaClass, MediaType, + SearchError, + SearchMedia, + SearchMediaQuery, ) from homeassistant.core import HomeAssistant @@ -20,13 +29,17 @@ from .const import DEFAULT_NAME, DOMAIN if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient +MEDIA_TYPE_AUDIOBOOK = "audiobook" MEDIA_TYPE_RADIO = "radio" PLAYABLE_MEDIA_TYPES = [ - MediaType.PLAYLIST, MediaType.ALBUM, MediaType.ARTIST, + MEDIA_TYPE_AUDIOBOOK, + MediaType.PLAYLIST, + MediaType.PODCAST, MEDIA_TYPE_RADIO, + MediaType.PODCAST, MediaType.TRACK, ] @@ -35,6 +48,8 @@ LIBRARY_ALBUMS = "albums" LIBRARY_TRACKS = "tracks" LIBRARY_PLAYLISTS = "playlists" LIBRARY_RADIO = "radio" +LIBRARY_PODCASTS = "podcasts" +LIBRARY_AUDIOBOOKS = "audiobooks" LIBRARY_TITLE_MAP = { @@ -43,6 +58,8 @@ LIBRARY_TITLE_MAP = { LIBRARY_TRACKS: "Tracks", LIBRARY_PLAYLISTS: "Playlists", LIBRARY_RADIO: "Radio stations", + LIBRARY_PODCASTS: "Podcasts", + LIBRARY_AUDIOBOOKS: "Audiobooks", } LIBRARY_MEDIA_CLASS_MAP = { @@ -51,10 +68,14 @@ LIBRARY_MEDIA_CLASS_MAP = { LIBRARY_TRACKS: MediaClass.TRACK, LIBRARY_PLAYLISTS: MediaClass.PLAYLIST, LIBRARY_RADIO: MediaClass.MUSIC, # radio is not accepted by HA + LIBRARY_PODCASTS: MediaClass.PODCAST, + LIBRARY_AUDIOBOOKS: MediaClass.DIRECTORY, # audiobook is not accepted by HA } MEDIA_CONTENT_TYPE_FLAC = "audio/flac" THUMB_SIZE = 200 +SORT_NAME_DESC = "sort_name_desc" +LOGGER = logging.getLogger(__name__) def media_source_filter(item: BrowseMedia) -> bool: @@ -89,13 +110,16 @@ async def async_browse_media( return await build_playlists_listing(mass) if media_content_id == LIBRARY_RADIO: return await build_radio_listing(mass) + if media_content_id == LIBRARY_PODCASTS: + return await build_podcasts_listing(mass) + if media_content_id == LIBRARY_AUDIOBOOKS: + return await build_audiobooks_listing(mass) if "artist" in media_content_id: return await build_artist_items_listing(mass, media_content_id) if "album" in media_content_id: return await build_album_items_listing(mass, media_content_id) if "playlist" in media_content_id: return await build_playlist_items_listing(mass, media_content_id) - raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}") @@ -148,16 +172,15 @@ async def build_playlists_listing(mass: MusicAssistantClient) -> BrowseMedia: can_play=False, can_expand=True, children_media_class=media_class, - children=sorted( - [ - build_item(mass, item, can_expand=True) - # we only grab the first page here because the - # HA media browser does not support paging - for item in await mass.music.get_library_playlists(limit=500) - if item.available - ], - key=lambda x: x.title, - ), + children=[ + build_item(mass, item, can_expand=True) + # we only grab the first page here because the + # HA media browser does not support paging + for item in await mass.music.get_library_playlists( + limit=500, order_by=SORT_NAME_DESC + ) + if item.available + ], ) @@ -201,16 +224,15 @@ async def build_artists_listing(mass: MusicAssistantClient) -> BrowseMedia: can_play=False, can_expand=True, children_media_class=media_class, - children=sorted( - [ - build_item(mass, artist, can_expand=True) - # we only grab the first page here because the - # HA media browser does not support paging - for artist in await mass.music.get_library_artists(limit=500) - if artist.available - ], - key=lambda x: x.title, - ), + children=[ + build_item(mass, artist, can_expand=True) + # we only grab the first page here because the + # HA media browser does not support paging + for artist in await mass.music.get_library_artists( + limit=500, order_by=SORT_NAME_DESC + ) + if artist.available + ], ) @@ -252,16 +274,15 @@ async def build_albums_listing(mass: MusicAssistantClient) -> BrowseMedia: can_play=False, can_expand=True, children_media_class=media_class, - children=sorted( - [ - build_item(mass, album, can_expand=True) - # we only grab the first page here because the - # HA media browser does not support paging - for album in await mass.music.get_library_albums(limit=500) - if album.available - ], - key=lambda x: x.title, - ), + children=[ + build_item(mass, album, can_expand=True) + # we only grab the first page here because the + # HA media browser does not support paging + for album in await mass.music.get_library_albums( + limit=500, order_by=SORT_NAME_DESC + ) + if album.available + ], ) @@ -301,16 +322,61 @@ async def build_tracks_listing(mass: MusicAssistantClient) -> BrowseMedia: can_play=False, can_expand=True, children_media_class=media_class, - children=sorted( - [ - build_item(mass, track, can_expand=False) - # we only grab the first page here because the - # HA media browser does not support paging - for track in await mass.music.get_library_tracks(limit=500) - if track.available - ], - key=lambda x: x.title, - ), + children=[ + build_item(mass, track, can_expand=False) + # we only grab the first page here because the + # HA media browser does not support paging + for track in await mass.music.get_library_tracks( + limit=500, order_by=SORT_NAME_DESC + ) + if track.available + ], + ) + + +async def build_podcasts_listing(mass: MusicAssistantClient) -> BrowseMedia: + """Build Podcasts browse listing.""" + media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_PODCASTS] + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=LIBRARY_PODCASTS, + media_content_type=MediaType.PODCAST, + title=LIBRARY_TITLE_MAP[LIBRARY_PODCASTS], + can_play=False, + can_expand=True, + children_media_class=media_class, + children=[ + build_item(mass, podcast, can_expand=False) + # we only grab the first page here because the + # HA media browser does not support paging + for podcast in await mass.music.get_library_podcasts( + limit=500, order_by=SORT_NAME_DESC + ) + if podcast.available + ], + ) + + +async def build_audiobooks_listing(mass: MusicAssistantClient) -> BrowseMedia: + """Build Audiobooks browse listing.""" + media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_AUDIOBOOKS] + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=LIBRARY_AUDIOBOOKS, + media_content_type=DOMAIN, + title=LIBRARY_TITLE_MAP[LIBRARY_AUDIOBOOKS], + can_play=False, + can_expand=True, + children_media_class=media_class, + children=[ + build_item(mass, audiobook, can_expand=False) + # we only grab the first page here because the + # HA media browser does not support paging + for audiobook in await mass.music.get_library_audiobooks( + limit=500, order_by=SORT_NAME_DESC + ) + if audiobook.available + ], ) @@ -329,7 +395,9 @@ async def build_radio_listing(mass: MusicAssistantClient) -> BrowseMedia: build_item(mass, track, can_expand=False, media_class=media_class) # we only grab the first page here because the # HA media browser does not support paging - for track in await mass.music.get_library_radios(limit=500) + for track in await mass.music.get_library_radios( + limit=500, order_by=SORT_NAME_DESC + ) if track.available ], ) @@ -360,3 +428,205 @@ def build_item( can_expand=can_expand, thumbnail=img_url, ) + + +async def _search_within_album( + mass: MusicAssistantClient, album_uri: str, search_query: str, limit: int +) -> SearchMedia: + """Search for tracks within a specific album.""" + album = await mass.music.get_item_by_uri(album_uri) + tracks = await mass.music.get_album_tracks(album.item_id, album.provider) + + # Filter tracks by search query + filtered_tracks = [ + track + for track in tracks + if search_query.lower() in track.name.lower() and track.available + ] + + return SearchMedia( + result=[ + build_item(mass, track, can_expand=False) + for track in filtered_tracks[:limit] + ] + ) + + +async def _search_within_artist( + mass: MusicAssistantClient, artist_uri: str, search_query: str, limit: int +) -> SearchResults: + """Search for content within an artist's catalog.""" + artist = await mass.music.get_item_by_uri(artist_uri) + search_query = f"{artist.name} - {search_query}" + return await mass.music.search( + search_query, + media_types=[MASSMediaType.ALBUM, MASSMediaType.TRACK], + limit=limit, + ) + + +def _get_media_types_from_query(query: SearchMediaQuery) -> list[MASSMediaType]: + """Map query to Music Assistant media types.""" + media_types: list[MASSMediaType] = [] + + match query.media_content_type: + case MediaType.ARTIST: + media_types = [MASSMediaType.ARTIST] + case MediaType.ALBUM: + media_types = [MASSMediaType.ALBUM] + case MediaType.TRACK: + media_types = [MASSMediaType.TRACK] + case MediaType.PLAYLIST: + media_types = [MASSMediaType.PLAYLIST] + case "radio": + media_types = [MASSMediaType.RADIO] + case "audiobook": + media_types = [MASSMediaType.AUDIOBOOK] + case MediaType.PODCAST: + media_types = [MASSMediaType.PODCAST] + case _: + # No specific type selected + if query.media_filter_classes: + # Map MediaClass to search types + mapping = { + MediaClass.ARTIST: MASSMediaType.ARTIST, + MediaClass.ALBUM: MASSMediaType.ALBUM, + MediaClass.TRACK: MASSMediaType.TRACK, + MediaClass.PLAYLIST: MASSMediaType.PLAYLIST, + MediaClass.MUSIC: MASSMediaType.RADIO, + MediaClass.DIRECTORY: MASSMediaType.AUDIOBOOK, + MediaClass.PODCAST: MASSMediaType.PODCAST, + } + media_types = [ + mapping[cls] for cls in query.media_filter_classes if cls in mapping + ] + + # Default to all types if none specified + if not media_types: + media_types = [ + MASSMediaType.ARTIST, + MASSMediaType.ALBUM, + MASSMediaType.TRACK, + MASSMediaType.PLAYLIST, + MASSMediaType.RADIO, + MASSMediaType.AUDIOBOOK, + MASSMediaType.PODCAST, + ] + + return media_types + + +def _process_search_results( + mass: MusicAssistantClient, + search_results: SearchResults, + media_types: list[MASSMediaType], +) -> list[BrowseMedia]: + """Process search results into BrowseMedia items.""" + result: list[BrowseMedia] = [] + + # Process search results for each media type + for media_type in media_types: + # Get items for each media type using pattern matching + items: list[MediaItemType] = [] + match media_type: + case MASSMediaType.ARTIST if search_results.artists: + # Cast to ensure type safety + items = cast(list[MediaItemType], search_results.artists) + case MASSMediaType.ALBUM if search_results.albums: + items = cast(list[MediaItemType], search_results.albums) + case MASSMediaType.TRACK if search_results.tracks: + items = cast(list[MediaItemType], search_results.tracks) + case MASSMediaType.PLAYLIST if search_results.playlists: + items = cast(list[MediaItemType], search_results.playlists) + case MASSMediaType.RADIO if search_results.radio: + items = cast(list[MediaItemType], search_results.radio) + case MASSMediaType.PODCAST if search_results.podcasts: + items = cast(list[MediaItemType], search_results.podcasts) + case MASSMediaType.AUDIOBOOK if search_results.audiobooks: + items = cast(list[MediaItemType], search_results.audiobooks) + case _: + continue + + # Add available items to results + for item in items: + if TYPE_CHECKING: + assert not isinstance(item, BrowseFolder) + if not item.available: + continue + + # Create browse item + # Convert to string to get the original value since we're using MASSMediaType enum + str_media_type = media_type.value.lower() + can_expand = _should_expand_media_type(str_media_type) + media_class = _get_media_class_for_type(str_media_type) + + browse_item = build_item( + mass, + item, + can_expand=can_expand, + media_class=media_class, + ) + result.append(browse_item) + + return result + + +def _should_expand_media_type(media_type: str) -> bool: + """Determine if a media type should be expandable.""" + return media_type in ("artist", "album", "playlist", "podcast") + + +def _get_media_class_for_type(media_type: str) -> MediaClass | None: + """Get the appropriate media class for a given media type.""" + mapping = { + "artist": LIBRARY_MEDIA_CLASS_MAP[LIBRARY_ARTISTS], + "album": LIBRARY_MEDIA_CLASS_MAP[LIBRARY_ALBUMS], + "track": LIBRARY_MEDIA_CLASS_MAP[LIBRARY_TRACKS], + "playlist": LIBRARY_MEDIA_CLASS_MAP[LIBRARY_PLAYLISTS], + "radio": LIBRARY_MEDIA_CLASS_MAP[LIBRARY_RADIO], + "podcast": LIBRARY_MEDIA_CLASS_MAP[LIBRARY_PODCASTS], + "audiobook": LIBRARY_MEDIA_CLASS_MAP[LIBRARY_AUDIOBOOKS], + } + return mapping.get(media_type) + + +async def async_search_media( + mass: MusicAssistantClient, + query: SearchMediaQuery, +) -> SearchMedia: + """Search media.""" + try: + search_query = query.search_query + limit = 5 # Default limit per media type + search_results: SearchResults | None = None + + # Handle media_content_id if provided (for contextual searches) + if query.media_content_id: + if "album/" in query.media_content_id: + return await _search_within_album( + mass, query.media_content_id, search_query, limit + ) + if "artist/" in query.media_content_id: + # For artists, we already run a search, so save the results + search_results = await _search_within_artist( + mass, query.media_content_id, search_query, limit + ) + + # Determine which media types to search + media_types = _get_media_types_from_query(query) + + # Execute search using the Music Assistant API if we haven't already done so + if search_results is None: + search_results = await mass.music.search( + search_query, media_types=media_types, limit=limit + ) + + # Process the search results + result = _process_search_results(mass, search_results, media_types) + return SearchMedia(result=result) + + except Exception as err: + LOGGER.debug( + "Search error details for %s: %s", query.search_query, err, exc_info=True + ) + raise SearchError(f"Error searching for {query.search_query}") from err diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 05fdbe85777..5dc8ab2ec00 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -36,6 +36,8 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType as HAMediaType, RepeatMode, + SearchMedia, + SearchMediaQuery, async_process_play_media_url, ) from homeassistant.const import ATTR_NAME, STATE_OFF @@ -74,7 +76,7 @@ from .const import ( DOMAIN, ) from .entity import MusicAssistantEntity -from .media_browser import async_browse_media +from .media_browser import async_browse_media, async_search_media from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item if TYPE_CHECKING: @@ -91,6 +93,7 @@ SUPPORTED_FEATURES_BASE = ( | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.SEARCH_MEDIA | MediaPlayerEntityFeature.MEDIA_ENQUEUE | MediaPlayerEntityFeature.MEDIA_ANNOUNCE | MediaPlayerEntityFeature.SEEK @@ -596,20 +599,34 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): media_content_type, ) + async def async_search_media(self, query: SearchMediaQuery) -> SearchMedia: + """Search media.""" + return await async_search_media( + self.mass, + query, + ) + def _update_media_image_url( self, player: Player, queue: PlayerQueue | None ) -> None: - """Update image URL for the active queue item.""" - if queue is None or queue.current_item is None: - self._attr_media_image_url = None - return - if image_url := self.mass.get_media_item_image_url(queue.current_item): + """Update image URL.""" + if queue and queue.current_item: + # image_url is provided by an music-assistant queue + image_url = self.mass.get_media_item_image_url(queue.current_item) + elif player.current_media and player.current_media.image_url: + # image_url is provided by an external source + image_url = player.current_media.image_url + else: + image_url = None + + # check if the image is provided via music-assistant and therefore + # not accessible from the outside + if image_url: self._attr_media_image_remotely_accessible = ( self.mass.server_url not in image_url ) - self._attr_media_image_url = image_url - return - self._attr_media_image_url = None + + self._attr_media_image_url = image_url def _update_media_attributes( self, player: Player, queue: PlayerQueue | None diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 371ecdc3a86..c7e7baf88f6 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -25,9 +25,9 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "Configuration flow is already in progress", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "reconfiguration_successful": "Successfully reconfigured the Music Assistant integration.", - "cannot_connect": "Failed to connect", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index 000dfe74112..b02eecaa41e 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -96,20 +96,20 @@ "pmsx003_caqi_level": { "name": "PMSx003 common air quality index level", "state": { - "very_low": "Very low", - "low": "Low", - "medium": "Medium", - "high": "High", - "very_high": "Very high" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" }, "state_attributes": { "options": { "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } } } @@ -129,20 +129,20 @@ "sds011_caqi_level": { "name": "SDS011 common air quality index level", "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" }, "state_attributes": { "options": { "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } } } @@ -165,20 +165,20 @@ "sps30_caqi_level": { "name": "SPS30 common air quality index level", "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" }, "state_attributes": { "options": { "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } } } diff --git a/homeassistant/components/national_grid_us/__init__.py b/homeassistant/components/national_grid_us/__init__.py new file mode 100644 index 00000000000..7db5e6e8160 --- /dev/null +++ b/homeassistant/components/national_grid_us/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: National Grid US.""" diff --git a/homeassistant/components/national_grid_us/manifest.json b/homeassistant/components/national_grid_us/manifest.json new file mode 100644 index 00000000000..88041ba2964 --- /dev/null +++ b/homeassistant/components/national_grid_us/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "national_grid_us", + "name": "National Grid US", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/neff/__init__.py b/homeassistant/components/neff/__init__.py new file mode 100644 index 00000000000..211ce088834 --- /dev/null +++ b/homeassistant/components/neff/__init__.py @@ -0,0 +1 @@ +"""Neff virtual integration.""" diff --git a/homeassistant/components/neff/manifest.json b/homeassistant/components/neff/manifest.json new file mode 100644 index 00000000000..1dfc91f94c9 --- /dev/null +++ b/homeassistant/components/neff/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "neff", + "name": "Neff", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 0a32777b527..84c8be1d0be 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==8.1.0"] + "requirements": ["pyatmo==9.0.0"] } diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 23b800e460d..580b49ea646 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -29,10 +29,10 @@ "public_weather": { "data": { "area_name": "Name of the area", - "lat_ne": "North-East corner latitude", - "lon_ne": "North-East corner longitude", - "lat_sw": "South-West corner latitude", - "lon_sw": "South-West corner longitude", + "lat_ne": "Northeast corner latitude", + "lon_ne": "Northeast corner longitude", + "lat_sw": "Southwest corner latitude", + "lon_sw": "Southwest corner longitude", "mode": "Calculation", "show_on_map": "Show on map" }, @@ -175,7 +175,7 @@ "state": { "frost_guard": "Frost guard", "schedule": "Schedule", - "manual": "Manual" + "manual": "[%key:common::state::manual%]" } } } @@ -206,13 +206,13 @@ "name": "Wind direction", "state": { "n": "North", - "ne": "North-east", + "ne": "Northeast", "e": "East", - "se": "South-east", + "se": "Southeast", "s": "South", - "sw": "South-west", + "sw": "Southwest", "w": "West", - "nw": "North-west" + "nw": "Northwest" } }, "wind_angle": { @@ -241,10 +241,10 @@ "name": "Reachability" }, "rf_strength": { - "name": "Radio" + "name": "RF strength" }, "wifi_strength": { - "name": "Wi-Fi" + "name": "Wi-Fi strength" }, "health_idx": { "name": "Health index", diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 200cce86997..14c7dc55cf0 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -4,12 +4,14 @@ from __future__ import annotations from ipaddress import IPv4Address, IPv6Address, ip_interface import logging +from pathlib import Path from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from homeassistant.loader import bind_hass +from homeassistant.util import package from . import util from .const import ( @@ -27,6 +29,19 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +def _check_docker_without_host_networking() -> bool: + """Check if we are not using host networking in Docker.""" + if not package.is_docker_env(): + # We are not in Docker, so we don't need to check for host networking + return True + + if Path("/proc/sys/net/ipv4/ip_forward").exists(): + # If we can read this file, we likely have host networking + return True + + return False + + @bind_hass async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]: """Get the network adapter configuration.""" @@ -166,5 +181,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_get_network(hass) + if not await hass.async_add_executor_job(_check_docker_without_host_networking): + docs_url = "https://docs.docker.com/network/network-tutorial-host/" + install_url = "https://www.home-assistant.io/installation/linux#install-home-assistant-container" + ir.async_create_issue( + hass, + DOMAIN, + "docker_host_network", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="docker_host_network", + learn_more_url=install_url, + translation_placeholders={"docs_url": docs_url, "install_url": install_url}, + ) + async_register_websocket_commands(hass) return True diff --git a/homeassistant/components/network/strings.json b/homeassistant/components/network/strings.json index 6aca7343221..3e135fff60b 100644 --- a/homeassistant/components/network/strings.json +++ b/homeassistant/components/network/strings.json @@ -6,5 +6,11 @@ "ipv6_addresses": "IPv6 addresses", "announce_addresses": "Announce addresses" } + }, + "issues": { + "docker_host_network": { + "title": "Home Assistant is not using host networking", + "description": "Home Assistant is running in a container without host networking mode. This can cause networking issues with device discovery, multicast, broadcast, other network features, and incorrectly detecting its own URL and IP addresses, causing issues with media players and sending audio responses to voice assistants.\n\nIt is recommended to run Home Assistant with host networking by adding the `--network host` flag to your Docker run command or setting `network_mode: host` in your `docker-compose.yml` file.\n\nSee the [Docker documentation]({docs_url}) for more information about Docker host networking and refer to the [Home Assistant installation guide]({install_url}) for our recommended and supported setup." + } } } diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index e9de81cca7c..e9637a16ae0 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -53,13 +53,18 @@ PARALLEL_UPDATES = 1 # keep data in sync with only one connection at a time SERVICE_SET_AIRCLEANER_MODE = "set_aircleaner_mode" SERVICE_SET_HUMIDIFY_SETPOINT = "set_humidify_setpoint" +SERVICE_SET_DEHUMIDIFY_SETPOINT = "set_dehumidify_setpoint" SERVICE_SET_HVAC_RUN_MODE = "set_hvac_run_mode" SET_AIRCLEANER_SCHEMA: VolDictType = { vol.Required(ATTR_AIRCLEANER_MODE): cv.string, } -SET_HUMIDITY_SCHEMA: VolDictType = { +SET_HUMIDIFY_SCHEMA: VolDictType = { + vol.Required(ATTR_HUMIDITY): vol.All(vol.Coerce(int), vol.Range(min=10, max=45)), +} + +SET_DEHUMIDIFY_SCHEMA: VolDictType = { vol.Required(ATTR_HUMIDITY): vol.All(vol.Coerce(int), vol.Range(min=35, max=65)), } @@ -126,9 +131,14 @@ async def async_setup_entry( platform.async_register_entity_service( SERVICE_SET_HUMIDIFY_SETPOINT, - SET_HUMIDITY_SCHEMA, + SET_HUMIDIFY_SCHEMA, f"async_{SERVICE_SET_HUMIDIFY_SETPOINT}", ) + platform.async_register_entity_service( + SERVICE_SET_DEHUMIDIFY_SETPOINT, + SET_DEHUMIDIFY_SCHEMA, + f"async_{SERVICE_SET_DEHUMIDIFY_SETPOINT}", + ) platform.async_register_entity_service( SERVICE_SET_AIRCLEANER_MODE, SET_AIRCLEANER_SCHEMA, @@ -224,20 +234,48 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): return self._zone.get_preset() async def async_set_humidity(self, humidity: int) -> None: - """Dehumidify target.""" - if self._thermostat.has_dehumidify_support(): - await self.async_set_dehumidify_setpoint(humidity) + """Set humidity targets. + + HA doesn't support separate humidify and dehumidify targets. + Set the target for the current mode if in [heat, cool] + otherwise set both targets to the clamped values. + """ + zone_current_mode = self._zone.get_current_mode() + if zone_current_mode == OPERATION_MODE_HEAT: + if self._thermostat.has_humidify_support(): + await self.async_set_humidify_setpoint(humidity) + elif zone_current_mode == OPERATION_MODE_COOL: + if self._thermostat.has_dehumidify_support(): + await self.async_set_dehumidify_setpoint(humidity) else: - await self.async_set_humidify_setpoint(humidity) + if self._thermostat.has_humidify_support(): + await self.async_set_humidify_setpoint(humidity) + if self._thermostat.has_dehumidify_support(): + await self.async_set_dehumidify_setpoint(humidity) self._signal_thermostat_update() @property - def target_humidity(self): - """Humidity indoors setpoint.""" + def target_humidity(self) -> float | None: + """Humidity indoors setpoint. + + In systems that support both humidification and dehumidification, + two values for target exist. We must choose one to return. + + :return: The target humidity setpoint. + """ + + # If heat is on, always return humidify value first + if ( + self._has_humidify_support + and self._zone.get_current_mode() == OPERATION_MODE_HEAT + ): + return percent_conv(self._thermostat.get_humidify_setpoint()) + # Fall back to previous behavior of returning dehumidify value then humidify if self._has_dehumidify_support: return percent_conv(self._thermostat.get_dehumidify_setpoint()) if self._has_humidify_support: return percent_conv(self._thermostat.get_humidify_setpoint()) + return None @property diff --git a/homeassistant/components/nexia/icons.json b/homeassistant/components/nexia/icons.json index a2157f5c035..c9434a332df 100644 --- a/homeassistant/components/nexia/icons.json +++ b/homeassistant/components/nexia/icons.json @@ -26,6 +26,9 @@ "set_humidify_setpoint": { "service": "mdi:water-percent" }, + "set_dehumidify_setpoint": { + "service": "mdi:water-percent" + }, "set_hvac_run_mode": { "service": "mdi:hvac" } diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index e7ab63d4712..e8a1b53cc08 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.4.0"] + "requirements": ["nexia==2.7.0"] } diff --git a/homeassistant/components/nexia/sensor.py b/homeassistant/components/nexia/sensor.py index 293a9308cb4..648b5dc3eeb 100644 --- a/homeassistant/components/nexia/sensor.py +++ b/homeassistant/components/nexia/sensor.py @@ -114,6 +114,35 @@ async def async_setup_entry( percent_conv, ) ) + # Heating Humidification Setpoint + if thermostat.has_humidify_support(): + entities.append( + NexiaThermostatSensor( + coordinator, + thermostat, + "get_humidify_setpoint", + "get_humidify_setpoint", + SensorDeviceClass.HUMIDITY, + PERCENTAGE, + SensorStateClass.MEASUREMENT, + percent_conv, + ) + ) + + # Cooling Dehumidification Setpoint + if thermostat.has_dehumidify_support(): + entities.append( + NexiaThermostatSensor( + coordinator, + thermostat, + "get_dehumidify_setpoint", + "get_dehumidify_setpoint", + SensorDeviceClass.HUMIDITY, + PERCENTAGE, + SensorStateClass.MEASUREMENT, + percent_conv, + ) + ) # Zone Sensors for zone_id in thermostat.get_zone_ids(): diff --git a/homeassistant/components/nexia/services.yaml b/homeassistant/components/nexia/services.yaml index ede1f311acf..d010676d14a 100644 --- a/homeassistant/components/nexia/services.yaml +++ b/homeassistant/components/nexia/services.yaml @@ -14,6 +14,20 @@ set_aircleaner_mode: - "quick" set_humidify_setpoint: + target: + entity: + integration: nexia + domain: climate + fields: + humidity: + required: true + selector: + number: + min: 10 + max: 45 + unit_of_measurement: "%" + +set_dehumidify_setpoint: target: entity: integration: nexia diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index 43da2cf05c7..f6b08d5e8e5 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -53,6 +53,12 @@ }, "zone_setpoint_status": { "name": "Zone setpoint status" + }, + "get_humidify_setpoint": { + "name": "Heating humidify setpoint" + }, + "get_dehumidify_setpoint": { + "name": "Cooling dehumidify setpoint" } }, "switch": { @@ -76,12 +82,22 @@ } }, "set_humidify_setpoint": { - "name": "Set humidify set point", - "description": "Sets the target humidity.", + "name": "Set humidify setpoint", + "description": "Sets the target humidity for heating.", "fields": { "humidity": { "name": "Humidity", - "description": "The humidification setpoint." + "description": "The setpoint for humidification when heating." + } + } + }, + "set_dehumidify_setpoint": { + "name": "Set dehumidify setpoint", + "description": "Sets the target humidity for cooling.", + "fields": { + "humidity": { + "name": "Humidity", + "description": "The setpoint for dehumidification when cooling." } } }, diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index 6300dc1cdc9..a4f6d54f58c 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nextbus", "iot_class": "cloud_polling", "loggers": ["py_nextbus"], - "requirements": ["py-nextbusnext==2.0.5"] + "requirements": ["py-nextbusnext==2.1.2"] } diff --git a/homeassistant/components/niko_home_control/quality_scale.yaml b/homeassistant/components/niko_home_control/quality_scale.yaml new file mode 100644 index 00000000000..390efb8fc90 --- /dev/null +++ b/homeassistant/components/niko_home_control/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not require polling. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: + status: todo + comment: | + Be more specific in the config flow with catching exceptions. + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: todo + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure + docs-installation-parameters: + status: exempt + comment: No options to configure + 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: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: done + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration does not require a websession. + strict-typing: todo diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json index 45212c0220b..8bb9a347373 100644 --- a/homeassistant/components/nina/manifest.json +++ b/homeassistant/components/nina/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/nina", "iot_class": "cloud_polling", "loggers": ["pynina"], - "requirements": ["PyNINA==0.3.4"], + "requirements": ["PyNINA==0.3.5"], "single_config_entry": true } diff --git a/homeassistant/components/nobo_hub/strings.json b/homeassistant/components/nobo_hub/strings.json index 1059934e896..5d1b8350edf 100644 --- a/homeassistant/components/nobo_hub/strings.json +++ b/homeassistant/components/nobo_hub/strings.json @@ -46,7 +46,7 @@ "global_override": { "name": "Global override", "state": { - "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "away": "[%key:common::state::not_home%]", "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", "eco": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", "none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::none%]" diff --git a/homeassistant/components/ntfy/__init__.py b/homeassistant/components/ntfy/__init__.py new file mode 100644 index 00000000000..cd9c35ca4e6 --- /dev/null +++ b/homeassistant/components/ntfy/__init__.py @@ -0,0 +1,78 @@ +"""The ntfy integration.""" + +from __future__ import annotations + +import logging + +from aiontfy import Ntfy +from aiontfy.exceptions import ( + NtfyConnectionError, + NtfyHTTPError, + NtfyTimeoutError, + NtfyUnauthorizedAuthenticationError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL, 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 + +_LOGGER = logging.getLogger(__name__) +PLATFORMS: list[Platform] = [Platform.NOTIFY] + + +type NtfyConfigEntry = ConfigEntry[Ntfy] + + +async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool: + """Set up ntfy from a config entry.""" + + session = async_get_clientsession(hass, entry.data.get(CONF_VERIFY_SSL, True)) + ntfy = Ntfy(entry.data[CONF_URL], session, token=entry.data.get(CONF_TOKEN)) + + try: + await ntfy.account() + except NtfyUnauthorizedAuthenticationError as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from e + except NtfyHTTPError as e: + _LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="server_error", + translation_placeholders={"error_msg": str(e.error)}, + ) from e + except NtfyConnectionError as e: + _LOGGER.debug("Error", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from e + except NtfyTimeoutError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="timeout_error", + ) from e + + entry.runtime_data = ntfy + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: NtfyConfigEntry) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ntfy/config_flow.py b/homeassistant/components/ntfy/config_flow.py new file mode 100644 index 00000000000..04a6730aa73 --- /dev/null +++ b/homeassistant/components/ntfy/config_flow.py @@ -0,0 +1,305 @@ +"""Config flow for the ntfy integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +import random +import re +import string +from typing import TYPE_CHECKING, Any + +from aiontfy import Ntfy +from aiontfy.exceptions import ( + NtfyException, + NtfyHTTPError, + NtfyUnauthorizedAuthenticationError, +) +import voluptuous as vol +from yarl import URL + +from homeassistant import data_entry_flow +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import ( + ATTR_CREDENTIALS, + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_TOPIC, DEFAULT_URL, DOMAIN, SECTION_AUTH + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL, default=DEFAULT_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + autocomplete="url", + ), + ), + vol.Required(CONF_VERIFY_SSL, default=True): bool, + vol.Required(SECTION_AUTH): data_entry_flow.section( + vol.Schema( + { + vol.Optional(CONF_USERNAME): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + autocomplete="username", + ), + ), + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), + } + ), + {"collapsed": True}, + ), + } +) + +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Exclusive(CONF_PASSWORD, ATTR_CREDENTIALS): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), + vol.Exclusive(CONF_TOKEN, ATTR_CREDENTIALS): str, + } +) + +STEP_USER_TOPIC_SCHEMA = vol.Schema( + { + vol.Required(CONF_TOPIC): str, + vol.Optional(CONF_NAME): str, + } +) + +RE_TOPIC = re.compile("^[-_a-zA-Z0-9]{1,64}$") + + +class NtfyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for ntfy.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"topic": TopicSubentryFlowHandler} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + url = URL(user_input[CONF_URL]) + username = user_input[SECTION_AUTH].get(CONF_USERNAME) + self._async_abort_entries_match( + { + CONF_URL: url.human_repr(), + CONF_USERNAME: username, + } + ) + session = async_get_clientsession(self.hass, user_input[CONF_VERIFY_SSL]) + if username: + ntfy = Ntfy( + user_input[CONF_URL], + session, + username, + user_input[SECTION_AUTH].get(CONF_PASSWORD, ""), + ) + else: + ntfy = Ntfy(user_input[CONF_URL], session) + + try: + account = await ntfy.account() + token = ( + (await ntfy.generate_token("Home Assistant")).token + if account.username != "*" + else None + ) + except NtfyUnauthorizedAuthenticationError: + errors["base"] = "invalid_auth" + except NtfyHTTPError as e: + _LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link) + errors["base"] = "cannot_connect" + except NtfyException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if TYPE_CHECKING: + assert url.host + return self.async_create_entry( + title=url.host, + data={ + CONF_URL: url.human_repr(), + CONF_USERNAME: username, + CONF_TOKEN: token, + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + ), + 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 reauthentication dialog.""" + errors: dict[str, str] = {} + + entry = self._get_reauth_entry() + + if user_input is not None: + session = async_get_clientsession(self.hass) + if token := user_input.get(CONF_TOKEN): + ntfy = Ntfy( + entry.data[CONF_URL], + session, + token=user_input[CONF_TOKEN], + ) + else: + ntfy = Ntfy( + entry.data[CONF_URL], + session, + username=entry.data[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ) + + try: + account = await ntfy.account() + token = ( + (await ntfy.generate_token("Home Assistant")).token + if not user_input.get(CONF_TOKEN) + else user_input[CONF_TOKEN] + ) + except NtfyUnauthorizedAuthenticationError: + errors["base"] = "invalid_auth" + except NtfyHTTPError as e: + _LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link) + errors["base"] = "cannot_connect" + except NtfyException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if entry.data[CONF_USERNAME] != account.username: + return self.async_abort( + reason="account_mismatch", + description_placeholders={ + CONF_USERNAME: entry.data[CONF_USERNAME], + "wrong_username": account.username, + }, + ) + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_TOKEN: token}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_REAUTH_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + description_placeholders={CONF_USERNAME: entry.data[CONF_USERNAME]}, + ) + + +class TopicSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for adding and modifying a topic.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add a new topic.""" + + return self.async_show_menu( + step_id="user", + menu_options=["add_topic", "generate_topic"], + ) + + async def async_step_generate_topic( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add a new topic.""" + topic = "".join( + random.choices( + string.ascii_lowercase + string.ascii_uppercase + string.digits, + k=16, + ) + ) + return self.async_show_form( + step_id="add_topic", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_TOPIC_SCHEMA, + suggested_values={CONF_TOPIC: topic}, + ), + ) + + async def async_step_add_topic( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add a new topic.""" + config_entry = self._get_entry() + errors: dict[str, str] = {} + + if user_input is not None: + if not RE_TOPIC.match(user_input[CONF_TOPIC]): + errors["base"] = "invalid_topic" + else: + for existing_subentry in config_entry.subentries.values(): + if existing_subentry.unique_id == user_input[CONF_TOPIC]: + return self.async_abort(reason="already_configured") + + return self.async_create_entry( + title=user_input.get(CONF_NAME, user_input[CONF_TOPIC]), + data=user_input, + unique_id=user_input[CONF_TOPIC], + ) + return self.async_show_form( + step_id="add_topic", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_TOPIC_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/ntfy/const.py b/homeassistant/components/ntfy/const.py new file mode 100644 index 00000000000..78355f7e828 --- /dev/null +++ b/homeassistant/components/ntfy/const.py @@ -0,0 +1,9 @@ +"""Constants for the ntfy integration.""" + +from typing import Final + +DOMAIN = "ntfy" +DEFAULT_URL: Final = "https://ntfy.sh" + +CONF_TOPIC = "topic" +SECTION_AUTH = "auth" diff --git a/homeassistant/components/ntfy/diagnostics.py b/homeassistant/components/ntfy/diagnostics.py new file mode 100644 index 00000000000..5be239dfef6 --- /dev/null +++ b/homeassistant/components/ntfy/diagnostics.py @@ -0,0 +1,29 @@ +"""Diagnostics platform for ntfy integration.""" + +from __future__ import annotations + +from typing import Any + +from yarl import URL + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant + +from . import NtfyConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: NtfyConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + url = URL(config_entry.data[CONF_URL]) + return { + CONF_URL: ( + url.human_repr() + if url.host == "ntfy.sh" + else url.with_host(REDACTED).human_repr() + ), + "topics": dict(config_entry.subentries), + } diff --git a/homeassistant/components/ntfy/icons.json b/homeassistant/components/ntfy/icons.json new file mode 100644 index 00000000000..9fe617880af --- /dev/null +++ b/homeassistant/components/ntfy/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "notify": { + "publish": { + "default": "mdi:console-line" + } + } + } +} diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json new file mode 100644 index 00000000000..95204444fbb --- /dev/null +++ b/homeassistant/components/ntfy/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ntfy", + "name": "ntfy", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ntfy", + "iot_class": "cloud_push", + "loggers": ["aionfty"], + "quality_scale": "bronze", + "requirements": ["aiontfy==0.5.1"] +} diff --git a/homeassistant/components/ntfy/notify.py b/homeassistant/components/ntfy/notify.py new file mode 100644 index 00000000000..7328a1533c2 --- /dev/null +++ b/homeassistant/components/ntfy/notify.py @@ -0,0 +1,97 @@ +"""ntfy notification entity.""" + +from __future__ import annotations + +from aiontfy import Message +from aiontfy.exceptions import ( + NtfyException, + NtfyHTTPError, + NtfyUnauthorizedAuthenticationError, +) +from yarl import URL + +from homeassistant.components.notify import ( + NotifyEntity, + NotifyEntityDescription, + NotifyEntityFeature, +) +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_NAME, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NtfyConfigEntry +from .const import CONF_TOPIC, DOMAIN + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NtfyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the ntfy notification entity platform.""" + + for subentry_id, subentry in config_entry.subentries.items(): + async_add_entities( + [NtfyNotifyEntity(config_entry, subentry)], config_subentry_id=subentry_id + ) + + +class NtfyNotifyEntity(NotifyEntity): + """Representation of a ntfy notification entity.""" + + entity_description = NotifyEntityDescription( + key="publish", + translation_key="publish", + name=None, + has_entity_name=True, + ) + _attr_supported_features = NotifyEntityFeature.TITLE + + def __init__( + self, + config_entry: NtfyConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize a notification entity.""" + + self._attr_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_{self.entity_description.key}" + self.topic = subentry.data[CONF_TOPIC] + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="ntfy LLC", + model="ntfy", + name=subentry.data.get(CONF_NAME, self.topic), + configuration_url=URL(config_entry.data[CONF_URL]) / self.topic, + identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")}, + ) + self.config_entry = config_entry + self.ntfy = config_entry.runtime_data + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Publish a message to a topic.""" + msg = Message(topic=self.topic, message=message, title=title) + try: + await self.ntfy.publish(msg) + except NtfyUnauthorizedAuthenticationError as e: + self.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from e + except NtfyHTTPError as e: + raise HomeAssistantError( + translation_key="publish_failed_request_error", + translation_domain=DOMAIN, + translation_placeholders={"error_msg": e.error}, + ) from e + except NtfyException as e: + raise HomeAssistantError( + translation_key="publish_failed_exception", + translation_domain=DOMAIN, + ) from e diff --git a/homeassistant/components/ntfy/quality_scale.yaml b/homeassistant/components/ntfy/quality_scale.yaml new file mode 100644 index 00000000000..0d075f0014b --- /dev/null +++ b/homeassistant/components/ntfy/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: only entity actions + appropriate-polling: + status: exempt + comment: the integration does not poll + brands: done + common-modules: + status: exempt + comment: the integration currently implements only one platform and has no coordinator + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: integration has only entity actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: the integration does not subscribe to events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: the integration has no options + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: the integration only implements a stateless notify entity. + integration-owner: done + log-when-unavailable: + status: exempt + comment: the integration only integrates state-less entities + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: + status: exempt + comment: no suitable device class for the notify entity + entity-disabled-by-default: + status: exempt + comment: only one entity + entity-translations: + status: exempt + comment: the notify entity uses the device name as entity name, no translation required + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: the integration has no repairs + stale-devices: + status: exempt + comment: only one device per entry, is deleted with the entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json new file mode 100644 index 00000000000..a48d158c896 --- /dev/null +++ b/homeassistant/components/ntfy/strings.json @@ -0,0 +1,117 @@ +{ + "common": { + "topic": "Topic", + "add_topic_description": "Set up a topic for notifications." + }, + "config": { + "step": { + "user": { + "description": "Set up **ntfy** push notification service", + "data": { + "url": "Service URL", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "url": "Address of the ntfy service. Modify this if you want to use a different server", + "verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to a ntfy instance using a self-signed certificate" + }, + "sections": { + "auth": { + "name": "Authentication", + "description": "Depending on whether the server is configured to support access control, some topics may be read/write protected so that only users with the correct credentials can subscribe or publish to them. To publish/subscribe to protected topics, you can provide a username and password.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Enter the username required to authenticate with protected ntfy topics", + "password": "Enter the password corresponding to the provided username for authentication" + } + } + } + }, + "reauth_confirm": { + "title": "Re-authenticate with ntfy ({name})", + "description": "The access token for **{username}** is invalid. To re-authenticate with the ntfy service, you can either log in with your password (a new access token will be created automatically) or you can directly provide a valid access token", + "data": { + "password": "[%key:common::config_flow::data::password%]", + "token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "password": "Enter the password corresponding to the aforementioned username to automatically create an access token", + "token": "Enter a new access token. To create a new access token navigate to Account → Access tokens and click create access token" + } + } + }, + "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_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "account_mismatch": "The provided access token corresponds to the account {wrong_username}. Please re-authenticate with with the account **{username}**" + } + }, + "config_subentries": { + "topic": { + "step": { + "user": { + "title": "[%key:component::ntfy::common::topic%]", + "description": "[%key:component::ntfy::common::add_topic_description%]", + "menu_options": { + "add_topic": "Enter topic", + "generate_topic": "Generate topic name" + } + }, + "add_topic": { + "title": "[%key:component::ntfy::common::topic%]", + "description": "[%key:component::ntfy::common::add_topic_description%]", + "data": { + "topic": "[%key:component::ntfy::common::topic%]", + "name": "Display name" + }, + "data_description": { + "topic": "Enter the name of the topic you want to use for notifications. Topics may not be password-protected, so choose a name that's not easy to guess.", + "name": "Set an alternative name to display instead of the topic name. This helps identify topics with complex or hard-to-read names more easily." + } + } + }, + "initiate_flow": { + "user": "Add topic" + }, + "entry_type": "[%key:component::ntfy::common::topic%]", + "error": { + "publish_forbidden": "Publishing to this topic is forbidden", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_topic": "Invalid topic. Only letters, numbers, underscores, or dashes allowed." + }, + "abort": { + "already_configured": "Topic is already configured" + } + } + }, + "exceptions": { + "publish_failed_request_error": { + "message": "Failed to publish notification: {error_msg}" + }, + + "publish_failed_exception": { + "message": "Failed to publish notification due to a connection error" + }, + "authentication_error": { + "message": "Failed to authenticate with ntfy service. Please verify your credentials" + }, + "server_error": { + "message": "Failed to connect to ntfy service due to a server error: {error_msg}" + }, + "connection_error": { + "message": "Failed to connect to ntfy service due to a connection error" + }, + "timeout_error": { + "message": "Failed to connect to ntfy service due to a connection timeout" + } + } +} diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index b2e039ec122..cfc147661ae 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -1,6 +1,6 @@ { "domain": "nuki", - "name": "Nuki", + "name": "Nuki Bridge", "codeowners": ["@pschmitt", "@pvizeli", "@pree"], "config_flow": true, "dependencies": ["webhook"], diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index daf47bc7de1..84e66c3db96 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -48,8 +48,8 @@ "state_attributes": { "battery_critical": { "state": { - "on": "[%key:component::binary_sensor::entity_component::battery::state::on%]", - "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]" + "on": "[%key:common::state::low%]", + "off": "[%key:common::state::normal%]" } } } diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index f44a510b1c0..280edb819d4 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -323,7 +323,7 @@ class NumberDeviceClass(StrEnum): REACTIVE_POWER = "reactive_power" """Reactive power. - Unit of measurement: `var` + Unit of measurement: `var`, `kvar` """ SIGNAL_STRENGTH = "signal_strength" @@ -497,7 +497,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth), NumberDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux), NumberDeviceClass.PRESSURE: set(UnitOfPressure), - NumberDeviceClass.REACTIVE_POWER: {UnitOfReactivePower.VOLT_AMPERE_REACTIVE}, + NumberDeviceClass.REACTIVE_POWER: set(UnitOfReactivePower), NumberDeviceClass.SIGNAL_STRENGTH: { SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 5b188868819..ae20ed39251 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -23,14 +23,10 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - DEFAULT_SCAN_INTERVAL, - DOMAIN, - INTEGRATION_SUPPORTED_COMMANDS, - PLATFORMS, -) +from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS, PLATFORMS NUT_FAKE_SERIAL = ["unknown", "blank"] @@ -68,7 +64,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: alias = config.get(CONF_ALIAS) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + if CONF_SCAN_INTERVAL in entry.options: + current_options = {**entry.options} + current_options.pop(CONF_SCAN_INTERVAL) + hass.config_entries.async_update_entry(entry, options=current_options) data = PyNUTData(host, port, alias, username, password) @@ -79,9 +78,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: try: return await data.async_update() except NUTLoginError as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="device_authentication", + translation_placeholders={ + "err": str(err), + }, + ) from err except NUTError as err: - raise UpdateFailed(f"Error fetching UPS state: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="data_fetch_error", + translation_placeholders={ + "err": str(err), + }, + ) from err coordinator = DataUpdateCoordinator( hass, @@ -89,7 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: config_entry=entry, name="NUT resource status", update_method=async_update_data, - update_interval=timedelta(seconds=scan_interval), + update_interval=timedelta(seconds=60), always_update=False, ) @@ -110,6 +121,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: if unique_id is None: unique_id = entry.entry_id + elif entry.unique_id is None: + hass.config_entries.async_update_entry(entry, unique_id=unique_id) + if username is not None and password is not None: # Dynamically add outlet integration commands additional_integration_commands = set() @@ -143,10 +157,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: coordinator, data, unique_id, user_available_commands ) + connections: set[tuple[str, str]] | None = None + if data.device_info.mac_address is not None: + connections = {(CONNECTION_NETWORK_MAC, data.device_info.mac_address)} + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, unique_id)}, + connections=connections, name=data.name.title(), manufacturer=data.device_info.manufacturer, model=data.device_info.model, @@ -161,12 +180,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: ConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Remove NUT config entry from a device.""" + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + and identifier[1] in config_entry.runtime_data.unique_id + ) + + +async def _async_update_listener(hass: HomeAssistant, entry: NutConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) @@ -234,6 +267,7 @@ class NUTDeviceInfo: model_id: str | None = None firmware: str | None = None serial: str | None = None + mac_address: str | None = None device_location: str | None = None @@ -297,9 +331,18 @@ class PyNUTData: model_id: str | None = self._status.get("device.part") firmware = _firmware_from_status(self._status) serial = _serial_from_status(self._status) + mac_address: str | None = self._status.get("device.macaddr") + if mac_address is not None: + mac_address = format_mac(mac_address.rstrip().replace(" ", ":")) device_location: str | None = self._status.get("device.location") return NUTDeviceInfo( - manufacturer, model, model_id, firmware, serial, device_location + manufacturer, + model, + model_id, + firmware, + serial, + mac_address, + device_location, ) async def _async_get_status(self) -> dict[str, str]: @@ -328,7 +371,12 @@ class PyNUTData: await self._client.run_command(self._alias, command_name) except NUTError as err: raise HomeAssistantError( - f"Error running command {command_name}, {err}" + translation_domain=DOMAIN, + translation_key="nut_command_error", + translation_placeholders={ + "command_name": command_name, + "err": str(err), + }, ) from err async def async_list_commands(self) -> set[str] | None: diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index b1b44966d14..69281e852a8 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -9,40 +9,43 @@ from typing import Any from aionut import NUTError, NUTLoginError import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_ALIAS, CONF_BASE, CONF_HOST, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_USERNAME, ) -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 +from . import PyNUTData, _unique_id_from_status +from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN _LOGGER = logging.getLogger(__name__) -AUTH_SCHEMA = {vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str} +REAUTH_SCHEMA = {vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str} + +PASSWORD_NOT_CHANGED = "__**password_not_changed**__" -def _base_schema(nut_config: dict[str, Any]) -> vol.Schema: +def _base_schema( + nut_config: Mapping[str, Any], + use_password_not_changed: bool = False, +) -> vol.Schema: """Generate base schema.""" base_schema = { vol.Optional(CONF_HOST, default=nut_config.get(CONF_HOST) or DEFAULT_HOST): str, vol.Optional(CONF_PORT, default=nut_config.get(CONF_PORT) or DEFAULT_PORT): int, + vol.Optional(CONF_USERNAME, default=nut_config.get(CONF_USERNAME) or ""): str, + vol.Optional( + CONF_PASSWORD, + default=PASSWORD_NOT_CHANGED if use_password_not_changed else "", + ): str, } - base_schema.update(AUTH_SCHEMA) + return vol.Schema(base_schema) @@ -51,7 +54,7 @@ def _ups_schema(ups_list: dict[str, str]) -> vol.Schema: return vol.Schema({vol.Required(CONF_ALIAS): vol.In(ups_list)}) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: +async def validate_input(data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from _base_schema with values provided by the user. @@ -72,6 +75,26 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, return {"ups_list": nut_data.ups_list, "available_resources": status} +def _check_host_port_alias_match( + first: Mapping[str, Any], second: Mapping[str, Any] +) -> bool: + """Check if first and second have the same host, port and alias.""" + + if first[CONF_HOST] != second[CONF_HOST] or first[CONF_PORT] != second[CONF_PORT]: + return False + + first_alias = first.get(CONF_ALIAS) + second_alias = second.get(CONF_ALIAS) + if (first_alias is None and second_alias is None) or ( + first_alias is not None + and second_alias is not None + and first_alias == second_alias + ): + return True + + return False + + def _format_host_port_alias(user_input: Mapping[str, Any]) -> str: """Format a host, port, and alias so it can be used for comparison or display.""" host = user_input[CONF_HOST] @@ -125,6 +148,11 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): if self._host_port_alias_already_configured(nut_config): return self.async_abort(reason="already_configured") + + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + title = _format_host_port_alias(nut_config) return self.async_create_entry(title=title, data=nut_config) @@ -138,7 +166,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_ups( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the picking the ups.""" + """Handle selecting the NUT device alias.""" errors: dict[str, str] = {} placeholders: dict[str, str] = {} nut_config = self.nut_config @@ -147,8 +175,13 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): self.nut_config.update(user_input) if self._host_port_alias_already_configured(nut_config): return self.async_abort(reason="already_configured") - _, errors, placeholders = await self._async_validate_or_error(nut_config) + + info, errors, placeholders = await self._async_validate_or_error(nut_config) if not errors: + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + title = _format_host_port_alias(nut_config) return self.async_create_entry(title=title, data=nut_config) @@ -159,6 +192,99 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders=placeholders, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + nut_config = self.nut_config + + if user_input is not None: + nut_config.update(user_input) + + info, errors, placeholders = await self._async_validate_or_error(nut_config) + + if not errors: + if len(info["ups_list"]) > 1: + self.ups_list = info["ups_list"] + return await self.async_step_reconfigure_ups() + + if not _check_host_port_alias_match( + reconfigure_entry.data, + nut_config, + ) and (self._host_port_alias_already_configured(nut_config)): + return self.async_abort(reason="already_configured") + + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_mismatch(reason="unique_id_mismatch") + if nut_config[CONF_PASSWORD] == PASSWORD_NOT_CHANGED: + nut_config.pop(CONF_PASSWORD) + + new_title = _format_host_port_alias(nut_config) + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + unique_id=unique_id, + title=new_title, + data_updates=nut_config, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=_base_schema( + reconfigure_entry.data, + use_password_not_changed=True, + ), + errors=errors, + description_placeholders=placeholders, + ) + + async def async_step_reconfigure_ups( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle selecting the NUT device alias.""" + + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + nut_config = self.nut_config + + if user_input is not None: + self.nut_config.update(user_input) + + if not _check_host_port_alias_match( + reconfigure_entry.data, + nut_config, + ) and (self._host_port_alias_already_configured(nut_config)): + return self.async_abort(reason="already_configured") + + info, errors, placeholders = await self._async_validate_or_error(nut_config) + if not errors: + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_mismatch(reason="unique_id_mismatch") + + if nut_config[CONF_PASSWORD] == PASSWORD_NOT_CHANGED: + nut_config.pop(CONF_PASSWORD) + + new_title = _format_host_port_alias(nut_config) + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + unique_id=unique_id, + title=new_title, + data_updates=nut_config, + ) + + return self.async_show_form( + step_id="reconfigure_ups", + data_schema=_ups_schema(self.ups_list or {}), + errors=errors, + description_placeholders=placeholders, + ) + def _host_port_alias_already_configured(self, user_input: dict[str, Any]) -> bool: """See if we already have a nut entry matching user input configured.""" existing_host_port_aliases = { @@ -175,7 +301,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): info: dict[str, Any] = {} description_placeholders: dict[str, str] = {} try: - info = await validate_input(self.hass, config) + info = await validate_input(config) except NUTLoginError: errors[CONF_PASSWORD] = "invalid_auth" except NUTError as ex: @@ -192,25 +318,24 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauth.""" - entry_id = self.context["entry_id"] - self.reauth_entry = self.hass.config_entries.async_get_entry(entry_id) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reauth input.""" + errors: dict[str, str] = {} - existing_entry = self.reauth_entry - assert existing_entry - existing_data = existing_entry.data + reauth_entry = self._get_reauth_entry() + reauth_data = reauth_entry.data description_placeholders: dict[str, str] = { - CONF_HOST: existing_data[CONF_HOST], - CONF_PORT: existing_data[CONF_PORT], + CONF_HOST: reauth_data[CONF_HOST], + CONF_PORT: reauth_data[CONF_PORT], } + if user_input is not None: new_config = { - **existing_data, + **reauth_data, # Username/password are optional and some servers # use ip based authentication and will fail if # username/password are provided @@ -219,43 +344,12 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): } _, errors, placeholders = await self._async_validate_or_error(new_config) if not errors: - return self.async_update_reload_and_abort( - existing_entry, data=new_config - ) + return self.async_update_reload_and_abort(reauth_entry, data=new_config) description_placeholders.update(placeholders) return self.async_show_form( - description_placeholders=description_placeholders, step_id="reauth_confirm", - data_schema=vol.Schema(AUTH_SCHEMA), + data_schema=vol.Schema(REAUTH_SCHEMA), errors=errors, + description_placeholders=description_placeholders, ) - - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: - """Get the options flow for this handler.""" - return OptionsFlowHandler() - - -class OptionsFlowHandler(OptionsFlow): - """Handle a option flow for nut.""" - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle options flow.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - scan_interval = self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ) - - base_schema = { - vol.Optional(CONF_SCAN_INTERVAL, default=scan_interval): vol.All( - vol.Coerce(int), vol.Clamp(min=10, max=300) - ) - } - - return self.async_show_form(step_id="init", data_schema=vol.Schema(base_schema)) diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index d741d8e95f9..175e971a12a 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -19,8 +19,6 @@ DEFAULT_PORT = 3493 KEY_STATUS = "ups.status" KEY_STATUS_DISPLAY = "ups.status.display" -DEFAULT_SCAN_INTERVAL = 60 - STATE_TYPES = { "OL": "Online", "OB": "On Battery", diff --git a/homeassistant/components/nut/device_action.py b/homeassistant/components/nut/device_action.py index ffaa195deaf..c622e63a12c 100644 --- a/homeassistant/components/nut/device_action.py +++ b/homeassistant/components/nut/device_action.py @@ -2,15 +2,18 @@ from __future__ import annotations +from typing import cast + import voluptuous as vol from homeassistant.components.device_automation import InvalidDeviceAutomationConfig +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import NutRuntimeData +from . import NutConfigEntry, NutRuntimeData from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS ACTION_TYPES = {cmd.replace(".", "_") for cmd in INTEGRATION_SUPPORTED_COMMANDS} @@ -48,12 +51,11 @@ async def async_call_action_from_config( device_action_name: str = config[CONF_TYPE] command_name = _get_command_name(device_action_name) device_id: str = config[CONF_DEVICE_ID] - runtime_data = _get_runtime_data_from_device_id(hass, device_id) - if not runtime_data: - raise InvalidDeviceAutomationConfig( - f"Unable to find a NUT device with id {device_id}" - ) - await runtime_data.data.async_run_command(command_name) + + if runtime_data := _get_runtime_data_from_device_id_exception_on_failure( + hass, device_id + ): + await runtime_data.data.async_run_command(command_name) def _get_device_action_name(command_name: str) -> str: @@ -65,13 +67,55 @@ def _get_command_name(device_action_name: str) -> str: def _get_runtime_data_from_device_id( - hass: HomeAssistant, device_id: str + hass: HomeAssistant, + device_id: str, ) -> NutRuntimeData | None: + """Find the runtime data for device ID and return None on error.""" device_registry = dr.async_get(hass) if (device := device_registry.async_get(device_id)) is None: return None - entry = hass.config_entries.async_get_entry( - next(entry_id for entry_id in device.config_entries) + return _get_runtime_data_for_device(hass, device) + + +def _get_runtime_data_for_device( + hass: HomeAssistant, device: dr.DeviceEntry +) -> NutRuntimeData | None: + """Find the runtime data for device and return None on error.""" + for config_entry_id in device.config_entries: + entry = hass.config_entries.async_get_entry(config_entry_id) + if ( + entry + and entry.domain == DOMAIN + and entry.state is ConfigEntryState.LOADED + and hasattr(entry, "runtime_data") + ): + return cast(NutConfigEntry, entry).runtime_data + + return None + + +def _get_runtime_data_from_device_id_exception_on_failure( + hass: HomeAssistant, + device_id: str, +) -> NutRuntimeData | None: + """Find the runtime data for device ID and raise exception on error.""" + device_registry = dr.async_get(hass) + if (device := device_registry.async_get(device_id)) is None: + raise InvalidDeviceAutomationConfig( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) + + if runtime_data := _get_runtime_data_for_device(hass, device): + return runtime_data + + raise InvalidDeviceAutomationConfig( + translation_domain=DOMAIN, + translation_key="config_invalid", + translation_placeholders={ + "device_id": device_id, + }, ) - assert entry and isinstance(entry.runtime_data, NutRuntimeData) - return entry.runtime_data diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index a795368005c..ae87c955164 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -1,10 +1,5 @@ { "entity": { - "button": { - "outlet_number_load_cycle": { - "default": "mdi:restart" - } - }, "sensor": { "ambient_humidity_status": { "default": "mdi:information-outline" diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 5bf7958e39e..ce8c10f8f41 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( PERCENTAGE, - STATE_UNKNOWN, EntityCategory, UnitOfApparentPower, UnitOfElectricCurrent, @@ -40,13 +39,31 @@ AMBIENT_SENSORS = { "ambient.temperature", "ambient.temperature.status", } -AMBIENT_THRESHOLD_STATUS_OPTIONS = [ +BATTERY_CHARGER_STATUS_OPTIONS = [ + "charging", + "discharging", + "floating", + "resting", + "unknown", + "disabled", + "off", +] +FREQUENCY_STATUS_OPTIONS = [ + "good", + "out-of-range", +] +THRESHOLD_STATUS_OPTIONS = [ "good", "warning-low", "critical-low", "warning-high", "critical-high", ] +UPS_BEEPER_STATUS_OPTIONS = [ + "enabled", + "disabled", + "muted", +] _LOGGER = logging.getLogger(__name__) @@ -64,7 +81,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ambient.humidity.status", translation_key="ambient_humidity_status", device_class=SensorDeviceClass.ENUM, - options=AMBIENT_THRESHOLD_STATUS_OPTIONS, + options=THRESHOLD_STATUS_OPTIONS, entity_category=EntityCategory.DIAGNOSTIC, ), "ambient.temperature": SensorEntityDescription( @@ -79,7 +96,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ambient.temperature.status", translation_key="ambient_temperature_status", device_class=SensorDeviceClass.ENUM, - options=AMBIENT_THRESHOLD_STATUS_OPTIONS, + options=THRESHOLD_STATUS_OPTIONS, entity_category=EntityCategory.DIAGNOSTIC, ), "battery.alarm.threshold": SensorEntityDescription( @@ -126,6 +143,8 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.charger.status": SensorEntityDescription( key="battery.charger.status", translation_key="battery_charger_status", + device_class=SensorDeviceClass.ENUM, + options=BATTERY_CHARGER_STATUS_OPTIONS, ), "battery.current": SensorEntityDescription( key="battery.current", @@ -374,6 +393,8 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.current.status": SensorEntityDescription( key="input.current.status", translation_key="input_current_status", + device_class=SensorDeviceClass.ENUM, + options=THRESHOLD_STATUS_OPTIONS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -397,6 +418,8 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.frequency.status": SensorEntityDescription( key="input.frequency.status", translation_key="input_frequency_status", + device_class=SensorDeviceClass.ENUM, + options=FREQUENCY_STATUS_OPTIONS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -792,6 +815,8 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.beeper.status": SensorEntityDescription( key="ups.beeper.status", translation_key="ups_beeper_status", + device_class=SensorDeviceClass.ENUM, + options=UPS_BEEPER_STATUS_OPTIONS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -1094,9 +1119,9 @@ class NUTSensor(NUTBaseEntity, SensorEntity): return status.get(self.entity_description.key) -def _format_display_state(status: dict[str, str]) -> str: +def _format_display_state(status: dict[str, str]) -> str | None: """Return UPS display state.""" try: - return " ".join(STATE_TYPES[state] for state in status[KEY_STATUS].split()) + return ", ".join(STATE_TYPES[state] for state in status[KEY_STATUS].split()) except KeyError: - return STATE_UNKNOWN + return None diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 4d8ffd45475..a9a3b470cca 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -10,13 +10,19 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your NUT server." + "host": "The IP address or hostname of your NUT server.", + "port": "The network port of your NUT server. The NUT server's default port is '3493'.", + "username": "The username to sign in to your NUT server. The username is optional.", + "password": "The password to sign in to your NUT server. The password is optional." } }, "ups": { - "title": "Choose the UPS to Monitor", + "title": "Choose the NUT server UPS to monitor", "data": { - "alias": "Alias" + "alias": "NUT server UPS name" + }, + "data_description": { + "alias": "The UPS name configured on the NUT server." } }, "reauth_confirm": { @@ -24,6 +30,34 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::nut::config::step::user::data_description::username%]", + "password": "[%key:component::nut::config::step::user::data_description::password%]" + } + }, + "reconfigure": { + "description": "[%key:component::nut::config::step::user::description%]", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "[%key:component::nut::config::step::user::data_description::host%]", + "port": "[%key:component::nut::config::step::user::data_description::port%]", + "username": "[%key:component::nut::config::step::user::data_description::username%]", + "password": "[%key:component::nut::config::step::user::data_description::password%]" + } + }, + "reconfigure_ups": { + "title": "[%key:component::nut::config::step::ups::title%]", + "data": { + "alias": "[%key:component::nut::config::step::ups::data::alias%]" + }, + "data_description": { + "alias": "[%key:component::nut::config::step::ups::data_description::alias%]" } } }, @@ -35,16 +69,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_ups_found": "There are no UPS devices available on the NUT server.", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" - } - }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Scan Interval (seconds)" - } - } + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The device's manufacturer, model and serial number identifier does not match the previous identifier." } }, "device_automation": { @@ -83,22 +110,57 @@ }, "sensor": { "ambient_humidity": { "name": "Ambient humidity" }, - "ambient_humidity_status": { "name": "Ambient humidity status" }, + "ambient_humidity_status": { + "name": "Ambient humidity status", + "state": { + "good": "Good", + "warning-low": "Warning low", + "critical-low": "Critical low", + "warning-high": "Warning high", + "critical-high": "Critical high" + } + }, "ambient_temperature": { "name": "Ambient temperature" }, - "ambient_temperature_status": { "name": "Ambient temperature status" }, + "ambient_temperature_status": { + "name": "Ambient temperature status", + "state": { + "good": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::good%]", + "warning-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-low%]", + "critical-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-low%]", + "warning-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-high%]", + "critical-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-high%]" + } + }, "battery_alarm_threshold": { "name": "Battery alarm threshold" }, "battery_capacity": { "name": "Battery capacity" }, "battery_charge": { "name": "Battery charge" }, "battery_charge_low": { "name": "Low battery setpoint" }, "battery_charge_restart": { "name": "Minimum battery to start" }, "battery_charge_warning": { "name": "Warning battery setpoint" }, - "battery_charger_status": { "name": "Charging status" }, + "battery_charger_status": { + "name": "Charging status", + "state": { + "charging": "[%key:common::state::charging%]", + "discharging": "[%key:common::state::discharging%]", + "floating": "Floating", + "resting": "Resting", + "unknown": "Unknown", + "disabled": "[%key:common::state::disabled%]", + "off": "[%key:common::state::off%]" + } + }, "battery_current": { "name": "Battery current" }, "battery_current_total": { "name": "Total battery current" }, "battery_date": { "name": "Battery date" }, "battery_mfr_date": { "name": "Battery manuf. date" }, - "battery_packs": { "name": "Number of batteries" }, - "battery_packs_bad": { "name": "Number of bad batteries" }, + "battery_packs": { + "name": "Number of batteries", + "unit_of_measurement": "packs" + }, + "battery_packs_bad": { + "name": "Number of bad batteries", + "unit_of_measurement": "packs" + }, "battery_runtime": { "name": "Battery runtime" }, "battery_runtime_low": { "name": "Low battery runtime" }, "battery_runtime_restart": { "name": "Minimum battery runtime to start" }, @@ -119,14 +181,32 @@ "input_bypass_l3_current": { "name": "Input bypass L3 current" }, "input_bypass_l3_n_voltage": { "name": "Input bypass L3-N voltage" }, "input_bypass_l3_realpower": { "name": "Input bypass L3 real power" }, - "input_bypass_phases": { "name": "Input bypass phases" }, + "input_bypass_phases": { + "name": "Input bypass phases", + "unit_of_measurement": "phase" + }, "input_bypass_realpower": { "name": "Input bypass real power" }, "input_bypass_voltage": { "name": "Input bypass voltage" }, "input_current": { "name": "Input current" }, - "input_current_status": { "name": "Input current status" }, + "input_current_status": { + "name": "Input current status", + "state": { + "good": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::good%]", + "warning-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-low%]", + "critical-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-low%]", + "warning-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-high%]", + "critical-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-high%]" + } + }, "input_frequency": { "name": "Input frequency" }, "input_frequency_nominal": { "name": "Input nominal frequency" }, - "input_frequency_status": { "name": "Input frequency status" }, + "input_frequency_status": { + "name": "Input frequency status", + "state": { + "good": "Good", + "out-of-range": "Out of range" + } + }, "input_l1_current": { "name": "Input L1 current" }, "input_l1_frequency": { "name": "Input L1 line frequency" }, "input_l1_n_voltage": { "name": "Input L1 voltage" }, @@ -140,7 +220,10 @@ "input_l3_n_voltage": { "name": "Input L3 voltage" }, "input_l3_realpower": { "name": "Input L3 real power" }, "input_load": { "name": "Input load" }, - "input_phases": { "name": "Input phases" }, + "input_phases": { + "name": "Input phases", + "unit_of_measurement": "phase" + }, "input_power": { "name": "Input power" }, "input_realpower": { "name": "Input real power" }, "input_sensitivity": { "name": "Input power sensitivity" }, @@ -174,7 +257,10 @@ "output_l3_n_voltage": { "name": "Output L3-N voltage" }, "output_l3_power_percent": { "name": "Output L3 power usage" }, "output_l3_realpower": { "name": "Output L3 real power" }, - "output_phases": { "name": "Output phases" }, + "output_phases": { + "name": "Output phases", + "unit_of_measurement": "phase" + }, "output_power": { "name": "Output apparent power" }, "output_power_nominal": { "name": "Nominal output power" }, "output_realpower": { "name": "Output real power" }, @@ -182,7 +268,14 @@ "output_voltage": { "name": "Output voltage" }, "output_voltage_nominal": { "name": "Nominal output voltage" }, "ups_alarm": { "name": "Alarms" }, - "ups_beeper_status": { "name": "Beeper status" }, + "ups_beeper_status": { + "name": "Beeper status", + "state": { + "enabled": "[%key:common::state::enabled%]", + "disabled": "[%key:common::state::disabled%]", + "muted": "Muted" + } + }, "ups_contacts": { "name": "External contacts" }, "ups_delay_reboot": { "name": "UPS reboot delay" }, "ups_delay_shutdown": { "name": "UPS shutdown delay" }, @@ -217,5 +310,22 @@ "switch": { "outlet_number_load_poweronoff": { "name": "Power outlet {outlet_name}" } } + }, + "exceptions": { + "config_invalid": { + "message": "Invalid configuration entries for NUT device with ID {device_id}" + }, + "data_fetch_error": { + "message": "Error fetching UPS state: {err}" + }, + "device_authentication": { + "message": "Device authentication error: {err}" + }, + "device_not_found": { + "message": "Unable to find a NUT device with ID {device_id}" + }, + "nut_command_error": { + "message": "Error running command {command_name}, {err}" + } } } diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 8a7631d8381..348d9ade7a3 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -2,9 +2,9 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime -from types import MappingProxyType from typing import Any from homeassistant.components.sensor import ( @@ -180,7 +180,7 @@ class NWSSensor(CoordinatorEntity[TimestampDataUpdateCoordinator[None]], SensorE def __init__( self, hass: HomeAssistant, - entry_data: MappingProxyType[str, Any], + entry_data: Mapping[str, Any], nws_data: NWSData, description: NWSSensorEntityDescription, station: str, diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index c90c67edcb7..c44869939ff 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping from functools import partial -from types import MappingProxyType from typing import Any, Required, TypedDict, cast import voluptuous as vol @@ -126,7 +126,7 @@ class ExtraForecast(TypedDict, total=False): short_description: str | None -def _calculate_unique_id(entry_data: MappingProxyType[str, Any], mode: str) -> str: +def _calculate_unique_id(entry_data: Mapping[str, Any], mode: str) -> str: """Calculate unique ID.""" latitude = entry_data[CONF_LATITUDE] longitude = entry_data[CONF_LONGITUDE] @@ -148,7 +148,7 @@ class NWSWeather(CoordinatorWeatherEntity[TimestampDataUpdateCoordinator[None]]) def __init__( self, - entry_data: MappingProxyType[str, Any], + entry_data: Mapping[str, Any], nws_data: NWSData, ) -> None: """Initialise the platform with a data instance and station name.""" diff --git a/homeassistant/components/ohme/__init__.py b/homeassistant/components/ohme/__init__.py index e3e252cbf8b..c304bfdf72d 100644 --- a/homeassistant/components/ohme/__init__.py +++ b/homeassistant/components/ohme/__init__.py @@ -6,6 +6,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS @@ -31,7 +32,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool: """Set up Ohme from a config entry.""" - client = OhmeApiClient(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]) + client = OhmeApiClient( + email=entry.data[CONF_EMAIL], + password=entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ) try: await client.async_login() diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 30a55360ce2..786c615d68a 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/ohme/", "integration_type": "device", "iot_class": "cloud_polling", - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["ohme==1.5.1"] } diff --git a/homeassistant/components/ohme/quality_scale.yaml b/homeassistant/components/ohme/quality_scale.yaml index f748cf339b4..2f7aece5bb6 100644 --- a/homeassistant/components/ohme/quality_scale.yaml +++ b/homeassistant/components/ohme/quality_scale.yaml @@ -48,17 +48,20 @@ rules: status: exempt comment: | All supported devices are cloud connected over mobile data. Discovery is not possible. - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: done - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo - dynamic-devices: todo - entity-category: todo + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + Not supported by the API. Accounts and devices have a one-to-one relationship. + entity-category: done entity-device-class: done - entity-disabled-by-default: todo + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: done @@ -67,8 +70,11 @@ rules: status: exempt comment: | This integration currently has no repairs. - stale-devices: todo + stale-devices: + status: exempt + comment: | + Not supported by the API. Accounts and devices have a one-to-one relationship. # Platinum - async-dependency: todo - inject-websession: todo - strict-typing: todo + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py index daee8fff13e..7047e33749f 100644 --- a/homeassistant/components/ohme/sensor.py +++ b/homeassistant/components/ohme/sensor.py @@ -99,6 +99,7 @@ SENSOR_ADVANCED_SETTINGS = [ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, value_fn=lambda client: client.power.ct_amps, is_supported_fn=lambda client: client.ct_connected, + entity_registry_enabled_default=False, ), ] diff --git a/homeassistant/components/ohme/services.py b/homeassistant/components/ohme/services.py index 249fb1abdab..8ed29aa373d 100644 --- a/homeassistant/components/ohme/services.py +++ b/homeassistant/components/ohme/services.py @@ -5,7 +5,7 @@ from typing import Final from ohme import OhmeApiClient import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -16,6 +16,7 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import selector from .const import DOMAIN +from .coordinator import OhmeConfigEntry ATTR_CONFIG_ENTRY: Final = "config_entry" ATTR_PRICE_CAP: Final = "price_cap" @@ -47,7 +48,7 @@ SERVICE_SET_PRICE_CAP_SCHEMA: Final = vol.Schema( def __get_client(call: ServiceCall) -> OhmeApiClient: """Get the client from the config entry.""" entry_id: str = call.data[ATTR_CONFIG_ENTRY] - entry: ConfigEntry | None = call.hass.config_entries.async_get_entry(entry_id) + entry: OhmeConfigEntry | None = call.hass.config_entries.async_get_entry(entry_id) if not entry: raise ServiceValidationError( diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 4a2170babeb..bcd9cfd17fe 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -89,7 +89,7 @@ "state": { "smart_charge": "Smart charge", "max_charge": "Max charge", - "paused": "Paused" + "paused": "[%key:common::state::paused%]" } }, "vehicle": { @@ -100,8 +100,8 @@ "status": { "name": "Status", "state": { - "unplugged": "Unplugged", - "plugged_in": "Plugged in", + "unplugged": "[%key:component::binary_sensor::entity_component::plug::state::off%]", + "plugged_in": "[%key:component::binary_sensor::entity_component::plug::state::on%]", "charging": "[%key:common::state::charging%]", "paused": "[%key:common::state::paused%]", "pending_approval": "Pending approval", @@ -140,7 +140,7 @@ }, "exceptions": { "auth_failed": { - "message": "Unable to login to Ohme" + "message": "Unable to log in to Ohme" }, "device_info_failed": { "message": "Unable to get Ohme device information" diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 1024a824c25..d7f874c261c 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -3,9 +3,9 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import logging import sys -from types import MappingProxyType from typing import Any import httpx @@ -215,13 +215,11 @@ class OllamaOptionsFlow(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: - if user_input[CONF_LLM_HASS_API] == "none": - user_input.pop(CONF_LLM_HASS_API) return self.async_create_entry( title=_get_title(self.model), data=user_input ) - options = self.config_entry.options or MappingProxyType({}) + options: Mapping[str, Any] = self.config_entry.options or {} schema = ollama_config_option_schema(self.hass, options) return self.async_show_form( step_id="init", @@ -230,22 +228,16 @@ class OllamaOptionsFlow(OptionsFlow): def ollama_config_option_schema( - hass: HomeAssistant, options: MappingProxyType[str, Any] + hass: HomeAssistant, options: Mapping[str, Any] ) -> dict: """Ollama options schema.""" hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label="No control", - value="none", - ) - ] - hass_apis.extend( SelectOptionDict( label=api.name, value=api.id, ) for api in llm.async_get_apis(hass) - ) + ] return { vol.Optional( @@ -259,8 +251,7 @@ def ollama_config_option_schema( vol.Optional( CONF_LLM_HASS_API, description={"suggested_value": options.get(CONF_LLM_HASS_API)}, - default="none", - ): SelectSelector(SelectSelectorConfig(options=hass_apis)), + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), vol.Optional( CONF_NUM_CTX, description={"suggested_value": options.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index c11bd79c377..097cddd6603 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -21,6 +21,7 @@ from .const import ( STEP_USER, STEPS, ) +from .views import BaseOnboardingView, NoAuthBaseOnboardingView # noqa: F401 STORAGE_KEY = DOMAIN STORAGE_VERSION = 4 diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json index a4cf814eb2a..e57857896e0 100644 --- a/homeassistant/components/onboarding/manifest.json +++ b/homeassistant/components/onboarding/manifest.json @@ -2,7 +2,7 @@ "domain": "onboarding", "name": "Home Assistant Onboarding", "codeowners": ["@home-assistant/core"], - "dependencies": ["auth", "http", "person"], + "dependencies": ["auth", "http"], "documentation": "https://www.home-assistant.io/integrations/onboarding", "integration_type": "system", "quality_scale": "internal" diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index a84aabe9b48..a42577b9f34 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -3,10 +3,9 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine -from functools import wraps from http import HTTPStatus -from typing import TYPE_CHECKING, Any, Concatenate, cast +import logging +from typing import TYPE_CHECKING, Any, Protocol, cast from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized @@ -16,22 +15,14 @@ from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import person from homeassistant.components.auth import indieauth -from homeassistant.components.backup import ( - BackupManager, - Folder, - IncorrectPasswordError, - http as backup_http, -) from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import area_registry as ar -from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager +from homeassistant.helpers import area_registry as ar, integration_platform from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations -from homeassistant.setup import SetupPhases, async_pause_setup, async_setup_component +from homeassistant.setup import async_setup_component, async_wait_component if TYPE_CHECKING: from . import OnboardingData, OnboardingStorage, OnboardingStoreData @@ -46,34 +37,76 @@ from .const import ( STEPS, ) +_LOGGER = logging.getLogger(__name__) + async def async_setup( hass: HomeAssistant, data: OnboardingStoreData, store: OnboardingStorage ) -> None: """Set up the onboarding view.""" - hass.http.register_view(OnboardingView(data, store)) + await async_process_onboarding_platforms(hass) + hass.http.register_view(OnboardingStatusView(data, store)) hass.http.register_view(InstallationTypeOnboardingView(data)) hass.http.register_view(UserOnboardingView(data, store)) hass.http.register_view(CoreConfigOnboardingView(data, store)) hass.http.register_view(IntegrationOnboardingView(data, store)) hass.http.register_view(AnalyticsOnboardingView(data, store)) - hass.http.register_view(BackupInfoView(data)) - hass.http.register_view(RestoreBackupView(data)) - hass.http.register_view(UploadBackupView(data)) - await setup_cloud_views(hass, data) + hass.http.register_view(WaitIntegrationOnboardingView(data)) -class OnboardingView(HomeAssistantView): - """Return the onboarding status.""" +class OnboardingPlatformProtocol(Protocol): + """Define the format of onboarding platforms.""" + + async def async_setup_views( + self, hass: HomeAssistant, data: OnboardingStoreData + ) -> None: + """Set up onboarding views.""" + + +async def async_process_onboarding_platforms(hass: HomeAssistant) -> None: + """Start processing onboarding platforms.""" + await integration_platform.async_process_integration_platforms( + hass, DOMAIN, _register_onboarding_platform, wait_for_platforms=False + ) + + +async def _register_onboarding_platform( + hass: HomeAssistant, integration_domain: str, platform: OnboardingPlatformProtocol +) -> None: + """Register a onboarding platform.""" + if not hasattr(platform, "async_setup_views"): + _LOGGER.debug( + "'%s.onboarding' is not a valid onboarding platform", + integration_domain, + ) + return + await platform.async_setup_views(hass, hass.data[DOMAIN].steps) + + +class BaseOnboardingView(HomeAssistantView): + """Base class for onboarding views.""" + + def __init__(self, data: OnboardingStoreData) -> None: + """Initialize the onboarding view.""" + self._data = data + + +class NoAuthBaseOnboardingView(BaseOnboardingView): + """Base class for unauthenticated onboarding views.""" requires_auth = False + + +class OnboardingStatusView(NoAuthBaseOnboardingView): + """Return the onboarding status.""" + url = "/api/onboarding" name = "api:onboarding" def __init__(self, data: OnboardingStoreData, store: OnboardingStorage) -> None: """Initialize the onboarding view.""" + super().__init__(data) self._store = store - self._data = data async def get(self, request: web.Request) -> web.Response: """Return the onboarding status.""" @@ -82,17 +115,12 @@ class OnboardingView(HomeAssistantView): ) -class InstallationTypeOnboardingView(HomeAssistantView): +class InstallationTypeOnboardingView(NoAuthBaseOnboardingView): """Return the installation type during onboarding.""" - requires_auth = False url = "/api/onboarding/installation_type" name = "api:onboarding:installation_type" - def __init__(self, data: OnboardingStoreData) -> None: - """Initialize the onboarding installation type view.""" - self._data = data - async def get(self, request: web.Request) -> web.Response: """Return the onboarding status.""" if self._data["done"]: @@ -103,15 +131,15 @@ class InstallationTypeOnboardingView(HomeAssistantView): return self.json({"installation_type": info["installation_type"]}) -class _BaseOnboardingView(HomeAssistantView): - """Base class for onboarding.""" +class _BaseOnboardingStepView(BaseOnboardingView): + """Base class for an onboarding step.""" step: str def __init__(self, data: OnboardingStoreData, store: OnboardingStorage) -> None: """Initialize the onboarding view.""" + super().__init__(data) self._store = store - self._data = data self._lock = asyncio.Lock() @callback @@ -131,7 +159,7 @@ class _BaseOnboardingView(HomeAssistantView): listener() -class UserOnboardingView(_BaseOnboardingView): +class UserOnboardingView(_BaseOnboardingStepView): """View to handle create user onboarding step.""" url = "/api/onboarding/users" @@ -169,7 +197,7 @@ class UserOnboardingView(_BaseOnboardingView): {"username": data["username"]} ) await hass.auth.async_link_user(user, credentials) - if "person" in hass.config.components: + if await async_wait_component(hass, "person"): await person.async_create_person(hass, data["name"], user_id=user.id) # Create default areas using the users supplied language. @@ -197,7 +225,7 @@ class UserOnboardingView(_BaseOnboardingView): return self.json({"auth_code": auth_code}) -class CoreConfigOnboardingView(_BaseOnboardingView): +class CoreConfigOnboardingView(_BaseOnboardingStepView): """View to finish core config onboarding step.""" url = "/api/onboarding/core_config" @@ -243,7 +271,7 @@ class CoreConfigOnboardingView(_BaseOnboardingView): return self.json({}) -class IntegrationOnboardingView(_BaseOnboardingView): +class IntegrationOnboardingView(_BaseOnboardingStepView): """View to finish integration onboarding step.""" url = "/api/onboarding/integration" @@ -290,7 +318,31 @@ class IntegrationOnboardingView(_BaseOnboardingView): return self.json({"auth_code": auth_code}) -class AnalyticsOnboardingView(_BaseOnboardingView): +class WaitIntegrationOnboardingView(NoAuthBaseOnboardingView): + """Get backup info view.""" + + url = "/api/onboarding/integration/wait" + name = "api:onboarding:integration:wait" + + @RequestDataValidator( + vol.Schema( + { + vol.Required("domain"): str, + } + ) + ) + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: + """Handle wait for integration command.""" + hass = request.app[KEY_HASS] + domain = data["domain"] + return self.json( + { + "integration_loaded": await async_wait_component(hass, domain), + } + ) + + +class AnalyticsOnboardingView(_BaseOnboardingStepView): """View to finish analytics onboarding step.""" url = "/api/onboarding/analytics" @@ -312,243 +364,6 @@ class AnalyticsOnboardingView(_BaseOnboardingView): return self.json({}) -class BackupOnboardingView(HomeAssistantView): - """Backup onboarding view.""" - - requires_auth = False - - def __init__(self, data: OnboardingStoreData) -> None: - """Initialize the view.""" - self._data = data - - -def with_backup_manager[_ViewT: BackupOnboardingView, **_P]( - func: Callable[ - Concatenate[_ViewT, BackupManager, web.Request, _P], - Coroutine[Any, Any, web.Response], - ], -) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]: - """Home Assistant API decorator to check onboarding and inject manager.""" - - @wraps(func) - async def with_backup( - self: _ViewT, - request: web.Request, - *args: _P.args, - **kwargs: _P.kwargs, - ) -> web.Response: - """Check admin and call function.""" - if self._data["done"]: - raise HTTPUnauthorized - - try: - manager = await async_get_backup_manager(request.app[KEY_HASS]) - except HomeAssistantError: - return self.json( - {"code": "backup_disabled"}, - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - - return await func(self, manager, request, *args, **kwargs) - - return with_backup - - -class BackupInfoView(BackupOnboardingView): - """Get backup info view.""" - - url = "/api/onboarding/backup/info" - name = "api:onboarding:backup:info" - - @with_backup_manager - async def get(self, manager: BackupManager, request: web.Request) -> web.Response: - """Return backup info.""" - backups, _ = await manager.async_get_backups() - return self.json( - { - "backups": list(backups.values()), - "state": manager.state, - "last_action_event": manager.last_action_event, - } - ) - - -class RestoreBackupView(BackupOnboardingView): - """Restore backup view.""" - - url = "/api/onboarding/backup/restore" - name = "api:onboarding:backup:restore" - - @RequestDataValidator( - vol.Schema( - { - vol.Required("backup_id"): str, - vol.Required("agent_id"): str, - vol.Optional("password"): str, - vol.Optional("restore_addons"): [str], - vol.Optional("restore_database", default=True): bool, - vol.Optional("restore_folders"): [vol.Coerce(Folder)], - } - ) - ) - @with_backup_manager - async def post( - self, manager: BackupManager, request: web.Request, data: dict[str, Any] - ) -> web.Response: - """Restore a backup.""" - try: - await manager.async_restore_backup( - data["backup_id"], - agent_id=data["agent_id"], - password=data.get("password"), - restore_addons=data.get("restore_addons"), - restore_database=data["restore_database"], - restore_folders=data.get("restore_folders"), - restore_homeassistant=True, - ) - except IncorrectPasswordError: - return self.json( - {"code": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST - ) - except HomeAssistantError as err: - return self.json( - {"code": "restore_failed", "message": str(err)}, - status_code=HTTPStatus.BAD_REQUEST, - ) - return web.Response(status=HTTPStatus.OK) - - -class UploadBackupView(BackupOnboardingView, backup_http.UploadBackupView): - """Upload backup view.""" - - url = "/api/onboarding/backup/upload" - name = "api:onboarding:backup:upload" - - @with_backup_manager - async def post(self, manager: BackupManager, request: web.Request) -> web.Response: - """Upload a backup file.""" - return await self._post(request) - - -async def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: - """Set up the cloud views.""" - - with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): - # Import the cloud integration in an executor to avoid blocking the - # event loop. - def import_cloud() -> None: - """Import the cloud integration.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.cloud import http_api # noqa: F401 - - await hass.async_add_import_executor_job(import_cloud) - - # The cloud integration is imported locally to avoid cloud being imported by - # bootstrap.py and to avoid circular imports. - - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.cloud import http_api as cloud_http - - # pylint: disable-next=import-outside-toplevel,hass-component-root-import - from homeassistant.components.cloud.const import DATA_CLOUD - - class CloudOnboardingView(HomeAssistantView): - """Cloud onboarding view.""" - - requires_auth = False - - def __init__(self, data: OnboardingStoreData) -> None: - """Initialize the view.""" - self._data = data - - def with_cloud[_ViewT: CloudOnboardingView, **_P]( - func: Callable[ - Concatenate[_ViewT, web.Request, _P], - Coroutine[Any, Any, web.Response], - ], - ) -> Callable[ - Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response] - ]: - """Home Assistant API decorator to check onboarding and cloud.""" - - @wraps(func) - async def _with_cloud( - self: _ViewT, - request: web.Request, - *args: _P.args, - **kwargs: _P.kwargs, - ) -> web.Response: - """Check onboarding status, cloud and call function.""" - if self._data["done"]: - # If at least one onboarding step is done, we don't allow accessing - # the cloud onboarding views. - raise HTTPUnauthorized - - hass = request.app[KEY_HASS] - if DATA_CLOUD not in hass.data: - return self.json( - {"code": "cloud_disabled"}, - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - - return await func(self, request, *args, **kwargs) - - return _with_cloud - - class CloudForgotPasswordView( - CloudOnboardingView, cloud_http.CloudForgotPasswordView - ): - """View to start Forgot Password flow.""" - - url = "/api/onboarding/cloud/forgot_password" - name = "api:onboarding:cloud:forgot_password" - - @with_cloud - async def post(self, request: web.Request) -> web.Response: - """Handle forgot password request.""" - return await super()._post(request) - - class CloudLoginView(CloudOnboardingView, cloud_http.CloudLoginView): - """Login to Home Assistant Cloud.""" - - url = "/api/onboarding/cloud/login" - name = "api:onboarding:cloud:login" - - @with_cloud - async def post(self, request: web.Request) -> web.Response: - """Handle login request.""" - return await super()._post(request) - - class CloudLogoutView(CloudOnboardingView, cloud_http.CloudLogoutView): - """Log out of the Home Assistant cloud.""" - - url = "/api/onboarding/cloud/logout" - name = "api:onboarding:cloud:logout" - - @with_cloud - async def post(self, request: web.Request) -> web.Response: - """Handle logout request.""" - return await super()._post(request) - - class CloudStatusView(CloudOnboardingView): - """Get cloud status view.""" - - url = "/api/onboarding/cloud/status" - name = "api:onboarding:cloud:status" - - @with_cloud - async def get(self, request: web.Request) -> web.Response: - """Return cloud status.""" - hass = request.app[KEY_HASS] - cloud = hass.data[DATA_CLOUD] - return self.json({"logged_in": cloud.is_logged_in}) - - hass.http.register_view(CloudForgotPasswordView(data)) - hass.http.register_view(CloudLoginView(data)) - hass.http.register_view(CloudLogoutView(data)) - hass.http.register_view(CloudStatusView(data)) - - @callback def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider: """Get the Home Assistant auth provider.""" diff --git a/homeassistant/components/oncue/__init__.py b/homeassistant/components/oncue/__init__.py index 19d134a398f..53c54290bf9 100644 --- a/homeassistant/components/oncue/__init__.py +++ b/homeassistant/components/oncue/__init__.py @@ -2,60 +2,40 @@ from __future__ import annotations -from datetime import timedelta -import logging - -from aiooncue import LoginFailedException, Oncue, OncueDevice - -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers import issue_registry as ir -from .const import CONNECTION_EXCEPTIONS, DOMAIN # noqa: F401 -from .types import OncueConfigEntry - -PLATFORMS: list[str] = [Platform.BINARY_SENSOR, Platform.SENSOR] - -_LOGGER = logging.getLogger(__name__) +DOMAIN = "oncue" -async def async_setup_entry(hass: HomeAssistant, entry: OncueConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: """Set up Oncue from a config entry.""" - data = entry.data - websession = async_get_clientsession(hass) - client = Oncue(data[CONF_USERNAME], data[CONF_PASSWORD], websession) - try: - await client.async_login() - except CONNECTION_EXCEPTIONS as ex: - raise ConfigEntryNotReady from ex - except LoginFailedException as ex: - raise ConfigEntryAuthFailed from ex - - async def _async_update() -> dict[str, OncueDevice]: - """Fetch data from Oncue.""" - try: - return await client.async_fetch_all() - except LoginFailedException as ex: - raise ConfigEntryAuthFailed from ex - - coordinator = DataUpdateCoordinator[dict[str, OncueDevice]]( + ir.async_create_issue( hass, - _LOGGER, - config_entry=entry, - name=f"Oncue {entry.data[CONF_USERNAME]}", - update_interval=timedelta(minutes=10), - update_method=_async_update, - always_update=False, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/oncue", + "rehlko": "/config/integrations/integration/rehlko", + }, ) - await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: OncueConfigEntry) -> bool: +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 True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/homeassistant/components/oncue/binary_sensor.py b/homeassistant/components/oncue/binary_sensor.py deleted file mode 100644 index 8dc9ba1be6f..00000000000 --- a/homeassistant/components/oncue/binary_sensor.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Support for Oncue binary sensors.""" - -from __future__ import annotations - -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 AddConfigEntryEntitiesCallback - -from .entity import OncueEntity -from .types import OncueConfigEntry - -SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( - BinarySensorEntityDescription( - key="NetworkConnectionEstablished", - entity_category=EntityCategory.DIAGNOSTIC, - device_class=BinarySensorDeviceClass.CONNECTIVITY, - ), -) - -SENSOR_MAP = {description.key: description for description in SENSOR_TYPES} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: OncueConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up binary sensors.""" - coordinator = config_entry.runtime_data - devices = coordinator.data - async_add_entities( - OncueBinarySensorEntity(coordinator, device_id, device, sensor, SENSOR_MAP[key]) - for device_id, device in devices.items() - for key, sensor in device.sensors.items() - if key in SENSOR_MAP - ) - - -class OncueBinarySensorEntity(OncueEntity, BinarySensorEntity): - """Representation of an Oncue binary sensor.""" - - @property - def is_on(self) -> bool: - """Return the binary sensor state.""" - return self._oncue_value == "true" diff --git a/homeassistant/components/oncue/config_flow.py b/homeassistant/components/oncue/config_flow.py index 872fe84350b..cf5b3262f0d 100644 --- a/homeassistant/components/oncue/config_flow.py +++ b/homeassistant/components/oncue/config_flow.py @@ -1,101 +1,11 @@ -"""Config flow for Oncue integration.""" +"""The Oncue integration.""" -from __future__ import annotations +from homeassistant.config_entries import ConfigFlow -from collections.abc import Mapping -import logging -from typing import Any - -from aiooncue import LoginFailedException, Oncue -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from .const import CONNECTION_EXCEPTIONS, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from . import DOMAIN class OncueConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Oncue.""" VERSION = 1 - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the initial step.""" - errors: dict[str, str] = {} - - if user_input is not None: - if not (errors := await self._async_validate_or_error(user_input)): - normalized_username = user_input[CONF_USERNAME].lower() - await self.async_set_unique_id(normalized_username) - self._abort_if_unique_id_configured( - updates={ - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], - } - ) - return self.async_create_entry( - title=normalized_username, data=user_input - ) - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } - ), - errors=errors, - ) - - async def _async_validate_or_error(self, config: dict[str, Any]) -> dict[str, str]: - """Validate the user input.""" - errors: dict[str, str] = {} - try: - await Oncue( - config[CONF_USERNAME], - config[CONF_PASSWORD], - async_get_clientsession(self.hass), - ).async_login() - except CONNECTION_EXCEPTIONS: - errors["base"] = "cannot_connect" - except LoginFailedException: - errors[CONF_PASSWORD] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - return errors - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Handle reauth.""" - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle reauth input.""" - errors: dict[str, str] = {} - reauth_entry = self._get_reauth_entry() - existing_data = reauth_entry.data - description_placeholders: dict[str, str] = { - CONF_USERNAME: existing_data[CONF_USERNAME] - } - if user_input is not None: - new_config = {**existing_data, CONF_PASSWORD: user_input[CONF_PASSWORD]} - if not (errors := await self._async_validate_or_error(new_config)): - return self.async_update_reload_and_abort(reauth_entry, data=new_config) - - return self.async_show_form( - description_placeholders=description_placeholders, - step_id="reauth_confirm", - data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), - errors=errors, - ) diff --git a/homeassistant/components/oncue/const.py b/homeassistant/components/oncue/const.py deleted file mode 100644 index bc14133b0d3..00000000000 --- a/homeassistant/components/oncue/const.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Constants for the Oncue integration.""" - -import aiohttp -from aiooncue import ServiceFailedException - -DOMAIN = "oncue" - -CONNECTION_EXCEPTIONS = ( - TimeoutError, - aiohttp.ClientError, - ServiceFailedException, -) - -CONNECTION_ESTABLISHED_KEY: str = "NetworkConnectionEstablished" - -VALUE_UNAVAILABLE: str = "--" diff --git a/homeassistant/components/oncue/entity.py b/homeassistant/components/oncue/entity.py deleted file mode 100644 index 55bd86d8912..00000000000 --- a/homeassistant/components/oncue/entity.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Support for Oncue sensors.""" - -from __future__ import annotations - -from aiooncue import OncueDevice, OncueSensor - -from homeassistant.const import ATTR_CONNECTIONS -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity, EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) - -from .const import CONNECTION_ESTABLISHED_KEY, DOMAIN, VALUE_UNAVAILABLE - - -class OncueEntity( - CoordinatorEntity[DataUpdateCoordinator[dict[str, OncueDevice]]], Entity -): - """Representation of an Oncue entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: DataUpdateCoordinator[dict[str, OncueDevice]], - device_id: str, - device: OncueDevice, - sensor: OncueSensor, - description: EntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self.entity_description = description - self._device_id = device_id - self._attr_unique_id = f"{device_id}_{description.key}" - self._attr_name = sensor.display_name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_id)}, - name=device.name, - hw_version=device.hardware_version, - sw_version=device.sensors["FirmwareVersion"].display_value, - model=device.sensors["GensetModelNumberSelect"].display_value, - manufacturer="Kohler", - ) - try: - mac_address_hex = hex(int(device.sensors["MacAddress"].value))[2:] - except ValueError: # MacAddress may be invalid if the gateway is offline - return - self._attr_device_info[ATTR_CONNECTIONS] = { - (dr.CONNECTION_NETWORK_MAC, mac_address_hex) - } - - @property - def _oncue_value(self) -> str: - """Return the sensor value.""" - device: OncueDevice = self.coordinator.data[self._device_id] - sensor: OncueSensor = device.sensors[self.entity_description.key] - return sensor.value - - @property - def available(self) -> bool: - """Return if entity is available.""" - # The binary sensor that tracks the connection should not go unavailable. - if self.entity_description.key != CONNECTION_ESTABLISHED_KEY: - # If Kohler returns -- the entity is unavailable. - if self._oncue_value == VALUE_UNAVAILABLE: - return False - # If the cloud is reporting that the generator is not connected - # this also indicates the data is not available. - # The battery voltage sensor reports 0.0 rather than - # -- hence the purpose of this check. - device: OncueDevice = self.coordinator.data[self._device_id] - conn_established: OncueSensor = device.sensors[CONNECTION_ESTABLISHED_KEY] - if ( - conn_established is not None - and conn_established.value == VALUE_UNAVAILABLE - ): - return False - return super().available diff --git a/homeassistant/components/oncue/manifest.json b/homeassistant/components/oncue/manifest.json index 33d56f23669..b3744c1bb65 100644 --- a/homeassistant/components/oncue/manifest.json +++ b/homeassistant/components/oncue/manifest.json @@ -1,16 +1,10 @@ { "domain": "oncue", "name": "Oncue by Kohler", - "codeowners": ["@bdraco", "@peterager"], - "config_flow": true, - "dhcp": [ - { - "hostname": "kohlergen*", - "macaddress": "00146F*" - } - ], + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/oncue", + "integration_type": "system", "iot_class": "cloud_polling", - "loggers": ["aiooncue"], - "requirements": ["aiooncue==0.3.9"] + "quality_scale": "legacy", + "requirements": [] } diff --git a/homeassistant/components/oncue/sensor.py b/homeassistant/components/oncue/sensor.py deleted file mode 100644 index 669c34157d4..00000000000 --- a/homeassistant/components/oncue/sensor.py +++ /dev/null @@ -1,217 +0,0 @@ -"""Support for Oncue sensors.""" - -from __future__ import annotations - -from aiooncue import OncueDevice, OncueSensor - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import ( - PERCENTAGE, - EntityCategory, - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfFrequency, - UnitOfPower, - UnitOfPressure, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .entity import OncueEntity -from .types import OncueConfigEntry - -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key="LatestFirmware", - icon="mdi:update", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineSpeed", - icon="mdi:speedometer", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineTargetSpeed", - icon="mdi:speedometer", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineOilPressure", - native_unit_of_measurement=UnitOfPressure.PSI, - device_class=SensorDeviceClass.PRESSURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineCoolantTemperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="BatteryVoltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="LubeOilTemperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GensetControllerTemperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineCompartmentTemperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GeneratorTrueTotalPower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GeneratorTruePercentOfRatedPower", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GeneratorVoltageAverageLineToLine", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GeneratorFrequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription(key="GensetState", icon="mdi:home-lightning-bolt"), - SensorEntityDescription( - key="GensetControllerTotalOperationTime", - icon="mdi:hours-24", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineTotalRunTime", - icon="mdi:hours-24", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineTotalRunTimeLoaded", - icon="mdi:hours-24", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription(key="AtsContactorPosition", icon="mdi:electric-switch"), - SensorEntityDescription( - key="IPAddress", - icon="mdi:ip-network", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="ConnectedServerIPAddress", - icon="mdi:server-network", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="Source1VoltageAverageLineToLine", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="Source2VoltageAverageLineToLine", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GensetTotalEnergy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - SensorEntityDescription( - key="EngineTotalNumberOfStarts", - icon="mdi:engine", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GeneratorCurrentAverage", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), -) - -SENSOR_MAP = {description.key: description for description in SENSOR_TYPES} - -UNIT_MAPPINGS = { - "C": UnitOfTemperature.CELSIUS, - "F": UnitOfTemperature.FAHRENHEIT, -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: OncueConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up sensors.""" - coordinator = config_entry.runtime_data - devices = coordinator.data - async_add_entities( - OncueSensorEntity(coordinator, device_id, device, sensor, SENSOR_MAP[key]) - for device_id, device in devices.items() - for key, sensor in device.sensors.items() - if key in SENSOR_MAP - ) - - -class OncueSensorEntity(OncueEntity, SensorEntity): - """Representation of an Oncue sensor.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator[dict[str, OncueDevice]], - device_id: str, - device: OncueDevice, - sensor: OncueSensor, - description: SensorEntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, device_id, device, sensor, description) - if not description.native_unit_of_measurement and sensor.unit is not None: - self._attr_native_unit_of_measurement = UNIT_MAPPINGS.get( - sensor.unit, sensor.unit - ) - - @property - def native_value(self) -> str: - """Return the sensors state.""" - return self._oncue_value diff --git a/homeassistant/components/oncue/strings.json b/homeassistant/components/oncue/strings.json index ce7561962a2..6581555ff9e 100644 --- a/homeassistant/components/oncue/strings.json +++ b/homeassistant/components/oncue/strings.json @@ -1,27 +1,8 @@ { - "config": { - "step": { - "user": { - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - }, - "reauth_confirm": { - "description": "Re-authenticate Oncue account {username}", - "data": { - "password": "[%key:common::config_flow::data::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%]" + "issues": { + "integration_removed": { + "title": "The Oncue integration has been removed", + "description": "The Oncue integration has been removed from Home Assistant.\n\nThe Oncue service has been discontinued and [Rehlko]({rehlko}) is the integration to keep using it.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Oncue integration entries]({entries})." } } } diff --git a/homeassistant/components/oncue/types.py b/homeassistant/components/oncue/types.py deleted file mode 100644 index 89dd7095d59..00000000000 --- a/homeassistant/components/oncue/types.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Support for Oncue types.""" - -from __future__ import annotations - -from aiooncue import OncueDevice - -from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -type OncueConfigEntry = ConfigEntry[DataUpdateCoordinator[dict[str, OncueDevice]]] diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 41a244506ea..dfb592c8d45 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -174,11 +174,15 @@ class OneDriveBackupAgent(BackupAgent): description = dumps(backup.as_dict()) _LOGGER.debug("Creating metadata: %s", description) metadata_filename = filename.rsplit(".", 1)[0] + ".metadata.json" - metadata_file = await self._client.upload_file( - self._folder_id, - metadata_filename, - description, - ) + try: + metadata_file = await self._client.upload_file( + self._folder_id, + metadata_filename, + description, + ) + except OneDriveException: + await self._client.delete_drive_item(backup_file.id) + raise # add metadata to the metadata file metadata_description = { @@ -186,10 +190,15 @@ class OneDriveBackupAgent(BackupAgent): "backup_id": backup.backup_id, "backup_file_id": backup_file.id, } - await self._client.update_drive_item( - path_or_id=metadata_file.id, - data=ItemUpdate(description=dumps(metadata_description)), - ) + try: + await self._client.update_drive_item( + path_or_id=metadata_file.id, + data=ItemUpdate(description=dumps(metadata_description)), + ) + except OneDriveException: + await self._client.delete_drive_item(backup_file.id) + await self._client.delete_drive_item(metadata_file.id) + raise self._cache_expiration = time() @handle_backup_errors @@ -235,8 +244,12 @@ class OneDriveBackupAgent(BackupAgent): items = await self._client.list_drive_items(self._folder_id) - async def download_backup_metadata(item_id: str) -> AgentBackup: - metadata_stream = await self._client.download_drive_item(item_id) + async def download_backup_metadata(item_id: str) -> AgentBackup | None: + try: + metadata_stream = await self._client.download_drive_item(item_id) + except OneDriveException as err: + _LOGGER.warning("Error downloading metadata for %s: %s", item_id, err) + return None metadata_json = loads(await metadata_stream.read()) return AgentBackup.from_dict(metadata_json) @@ -246,6 +259,8 @@ class OneDriveBackupAgent(BackupAgent): metadata_description_json := unescape(item.description) ): backup = await download_backup_metadata(item.id) + if backup is None: + continue metadata_description = loads(metadata_description_json) backups[backup.backup_id] = OneDriveBackup( backup=backup, diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index c3d98200b03..c20a99c727e 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "platinum", - "requirements": ["onedrive-personal-sdk==0.0.13"] + "requirements": ["onedrive-personal-sdk==0.0.14"] } diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 90fa4efc3ec..b8fa7f8189d 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -124,7 +124,7 @@ "drive_state": { "name": "Drive state", "state": { - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "nearing": "Nearing limit", "critical": "Critical", "exceeded": "Exceeded" diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 5e1c7d35bd6..7039dc09858 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -7,7 +7,6 @@ import dataclasses from datetime import timedelta import logging import os -from types import MappingProxyType from typing import Any from pyownet import protocol @@ -415,7 +414,7 @@ async def async_setup_entry( def get_entities( onewire_hub: OneWireHub, devices: list[OWDeviceDescription], - options: MappingProxyType[str, Any], + options: Mapping[str, Any], ) -> list[OneWireSensorEntity]: """Get a list of entities.""" entities: list[OneWireSensorEntity] = [] diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 5d941be959a..85ff0de3251 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import section from homeassistant.helpers.selector import ( @@ -30,8 +30,6 @@ from homeassistant.helpers.selector import ( from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( - CONF_RECEIVER_MAX_VOLUME, - CONF_SOURCES, DOMAIN, OPTION_INPUT_SOURCES, OPTION_LISTENING_MODES, @@ -329,61 +327,6 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): """Handle reconfiguration of the receiver.""" return await self.async_step_manual() - async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: - """Import the yaml config.""" - _LOGGER.debug("Import flow user input: %s", user_input) - - host: str = user_input[CONF_HOST] - name: str | None = user_input.get(CONF_NAME) - user_max_volume: int = user_input[OPTION_MAX_VOLUME] - user_volume_resolution: int = user_input[CONF_RECEIVER_MAX_VOLUME] - user_sources: dict[InputSource, str] = user_input[CONF_SOURCES] - - info: ReceiverInfo | None = user_input.get("info") - if info is None: - try: - info = await async_interview(host) - except Exception: - _LOGGER.exception("Import flow interview error for host %s", host) - return self.async_abort(reason="cannot_connect") - - if info is None: - _LOGGER.error("Import flow interview error for host %s", host) - return self.async_abort(reason="cannot_connect") - - unique_id = info.identifier - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - - name = name or info.model_name - - volume_resolution = VOLUME_RESOLUTION_ALLOWED[-1] - for volume_resolution_allowed in VOLUME_RESOLUTION_ALLOWED: - if user_volume_resolution <= volume_resolution_allowed: - volume_resolution = volume_resolution_allowed - break - - max_volume = min( - 100, user_max_volume * user_volume_resolution / volume_resolution - ) - - sources_store: dict[str, str] = {} - for source, source_name in user_sources.items(): - sources_store[source.value] = source_name - - return self.async_create_entry( - title=name, - data={ - CONF_HOST: host, - }, - options={ - OPTION_VOLUME_RESOLUTION: volume_resolution, - OPTION_MAX_VOLUME: max_volume, - OPTION_INPUT_SOURCES: sources_store, - OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, - }, - ) - @staticmethod @callback def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: diff --git a/homeassistant/components/onkyo/const.py b/homeassistant/components/onkyo/const.py index fcb1a8a0a9e..851d80c5100 100644 --- a/homeassistant/components/onkyo/const.py +++ b/homeassistant/components/onkyo/const.py @@ -11,9 +11,6 @@ DOMAIN = "onkyo" DEVICE_INTERVIEW_TIMEOUT = 5 DEVICE_DISCOVERY_TIMEOUT = 5 -CONF_SOURCES = "sources" -CONF_RECEIVER_MAX_VOLUME = "receiver_max_volume" - type VolumeResolution = Literal[50, 80, 100, 200] OPTION_VOLUME_RESOLUTION = "volume_resolution" OPTION_VOLUME_RESOLUTION_DEFAULT: VolumeResolution = 50 diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index f7fe83c57a3..aed7c51af80 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -8,32 +8,18 @@ from functools import cache import logging from typing import Any, Literal -import voluptuous as vol - from homeassistant.components.media_player import ( - PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_platform import ( - AddConfigEntryEntitiesCallback, - AddEntitiesCallback, -) -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OnkyoConfigEntry from .const import ( - CONF_RECEIVER_MAX_VOLUME, - CONF_SOURCES, DOMAIN, OPTION_MAX_VOLUME, OPTION_VOLUME_RESOLUTION, @@ -43,46 +29,11 @@ from .const import ( ListeningMode, VolumeResolution, ) -from .receiver import Receiver, async_discover +from .receiver import Receiver from .services import DATA_MP_ENTITIES _LOGGER = logging.getLogger(__name__) -CONF_MAX_VOLUME_DEFAULT = 100 -CONF_RECEIVER_MAX_VOLUME_DEFAULT = 80 -CONF_SOURCES_DEFAULT = { - "tv": "TV", - "bd": "Bluray", - "game": "Game", - "aux1": "Aux1", - "video1": "Video 1", - "video2": "Video 2", - "video3": "Video 3", - "video4": "Video 4", - "video5": "Video 5", - "video6": "Video 6", - "video7": "Video 7", - "fm": "Radio", -} - -ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo" - -PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(OPTION_MAX_VOLUME, default=CONF_MAX_VOLUME_DEFAULT): vol.All( - vol.Coerce(int), vol.Range(min=1, max=100) - ), - vol.Optional( - CONF_RECEIVER_MAX_VOLUME, default=CONF_RECEIVER_MAX_VOLUME_DEFAULT - ): cv.positive_int, - vol.Optional(CONF_SOURCES, default=CONF_SOURCES_DEFAULT): { - cv.string: cv.string - }, - } -) - SUPPORTED_FEATURES_BASE = ( MediaPlayerEntityFeature.TURN_ON @@ -194,122 +145,6 @@ def _rev_listening_mode_lib_mappings(zone: str) -> dict[LibValue, ListeningMode] return {value: key for key, value in _listening_mode_lib_mappings(zone).items()} -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Import config from yaml.""" - host = config.get(CONF_HOST) - - source_mapping: dict[str, InputSource] = {} - for zone in ZONES: - for source, source_lib in _input_source_lib_mappings(zone).items(): - if isinstance(source_lib, str): - source_mapping.setdefault(source_lib, source) - else: - for source_lib_single in source_lib: - source_mapping.setdefault(source_lib_single, source) - - sources: dict[InputSource, str] = {} - for source_lib_single, source_name in config[CONF_SOURCES].items(): - user_source = source_mapping.get(source_lib_single.lower()) - if user_source is not None: - sources[user_source] = source_name - - config[CONF_SOURCES] = sources - - results = [] - if host is not None: - _LOGGER.debug("Importing yaml single: %s", host) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - results.append((host, result)) - else: - for info in await async_discover(): - host = info.host - - # Migrate legacy entities. - registry = er.async_get(hass) - old_unique_id = f"{info.model_name}_{info.identifier}" - new_unique_id = f"{info.identifier}_main" - entity_id = registry.async_get_entity_id( - "media_player", DOMAIN, old_unique_id - ) - if entity_id is not None: - _LOGGER.debug( - "Migrating unique_id from [%s] to [%s] for entity %s", - old_unique_id, - new_unique_id, - entity_id, - ) - registry.async_update_entity(entity_id, new_unique_id=new_unique_id) - - _LOGGER.debug("Importing yaml discover: %s", info.host) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config | {CONF_HOST: info.host} | {"info": info}, - ) - results.append((host, result)) - - _LOGGER.debug("Importing yaml results: %s", results) - if not results: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_import_issue_no_discover", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml_import_issue_no_discover", - translation_placeholders={"url": ISSUE_URL_PLACEHOLDER}, - ) - - all_successful = True - for host, result in results: - if ( - result.get("type") == FlowResultType.CREATE_ENTRY - or result.get("reason") == "already_configured" - ): - continue - if error := result.get("reason"): - all_successful = False - async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{host}_{error}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{error}", - translation_placeholders={ - "host": host, - "url": ISSUE_URL_PLACEHOLDER, - }, - ) - - if all_successful: - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - is_fixable=False, - issue_domain=DOMAIN, - breaks_in_ha_version="2025.5.0", - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "onkyo", - }, - ) - - async def async_setup_entry( hass: HomeAssistant, entry: OnkyoConfigEntry, diff --git a/homeassistant/components/onkyo/strings.json b/homeassistant/components/onkyo/strings.json index d8131dd1149..3e5520c79f7 100644 --- a/homeassistant/components/onkyo/strings.json +++ b/homeassistant/components/onkyo/strings.json @@ -83,16 +83,6 @@ "empty_listening_mode_list": "Listening mode list cannot be empty" } }, - "issues": { - "deprecated_yaml_import_issue_no_discover": { - "title": "The Onkyo YAML configuration import failed", - "description": "Configuring Onkyo using YAML is being removed but no receivers were discovered when importing your YAML configuration.\n\nEnsure the connection to the receiver works and restart Home Assistant to try again or remove the Onkyo YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." - }, - "deprecated_yaml_import_issue_cannot_connect": { - "title": "The Onkyo YAML configuration import failed", - "description": "Configuring Onkyo using YAML is being removed but there was a connection error when importing your YAML configuration for host {host}.\n\nEnsure the connection to the receiver works and restart Home Assistant to try again or remove the Onkyo YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." - } - }, "exceptions": { "invalid_sound_mode": { "message": "Cannot select sound mode \"{invalid_sound_mode}\" for entity: {entity_id}." diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index fcf6ab298dc..7da1becd333 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -11,6 +11,7 @@ from openai.types.images_response import ImagesResponse from openai.types.responses import ( EasyInputMessageParam, Response, + ResponseInputFileParam, ResponseInputImageParam, ResponseInputMessageContentListParam, ResponseInputParam, @@ -100,6 +101,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: except openai.OpenAIError as err: raise HomeAssistantError(f"Error generating image: {err}") from err + if not response.data or not response.data[0].url: + raise HomeAssistantError("No image returned") + return response.data[0].model_dump(exclude={"b64_json"}) async def send_prompt(call: ServiceCall) -> ServiceResponse: @@ -132,19 +136,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if not Path(filename).exists(): raise HomeAssistantError(f"`{filename}` does not exist") mime_type, base64_file = encode_file(filename) - if "image/" not in mime_type: + if "image/" in mime_type: + content.append( + ResponseInputImageParam( + type="input_image", + file_id=filename, + image_url=f"data:{mime_type};base64,{base64_file}", + detail="auto", + ) + ) + elif "application/pdf" in mime_type: + content.append( + ResponseInputFileParam( + type="input_file", + filename=filename, + file_data=f"data:{mime_type};base64,{base64_file}", + ) + ) + else: raise HomeAssistantError( - "Only images are supported by the OpenAI API," - f"`{filename}` is not an image file" + "Only images and PDF are supported by the OpenAI API," + f"`{filename}` is not an image file or PDF" ) - content.append( - ResponseInputImageParam( - type="input_image", - file_id=filename, - image_url=f"data:{mime_type};base64,{base64_file}", - detail="auto", - ) - ) if CONF_FILENAMES in call.data: await hass.async_add_executor_job(append_files_to_content) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 7304eb52da3..fbe64492b3c 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import json import logging from types import MappingProxyType @@ -63,6 +64,7 @@ from .const import ( RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_USER_LOCATION, UNSUPPORTED_MODELS, + WEB_SEARCH_MODELS, ) _LOGGER = logging.getLogger(__name__) @@ -153,16 +155,16 @@ class OpenAIOptionsFlow(OptionsFlow): if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: - if user_input[CONF_LLM_HASS_API] == "none": - user_input.pop(CONF_LLM_HASS_API) - + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS: errors[CONF_CHAT_MODEL] = "model_not_supported" if user_input.get(CONF_WEB_SEARCH): - if not user_input.get( - CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL - ).startswith("gpt-4o"): + if ( + user_input.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + not in WEB_SEARCH_MODELS + ): errors[CONF_WEB_SEARCH] = "web_search_not_supported" elif user_input.get(CONF_WEB_SEARCH_USER_LOCATION): user_input.update(await self.get_location_data()) @@ -176,7 +178,7 @@ class OpenAIOptionsFlow(OptionsFlow): options = { CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], + CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API), } schema = openai_config_option_schema(self.hass, options) @@ -242,23 +244,20 @@ class OpenAIOptionsFlow(OptionsFlow): def openai_config_option_schema( hass: HomeAssistant, - options: dict[str, Any] | MappingProxyType[str, Any], + options: Mapping[str, Any], ) -> VolDictType: """Return a schema for OpenAI completion options.""" hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label="No control", - value="none", - ) - ] - hass_apis.extend( SelectOptionDict( label=api.name, value=api.id, ) for api in llm.async_get_apis(hass) - ) - + ] + if (suggested_llm_apis := options.get(CONF_LLM_HASS_API)) and isinstance( + suggested_llm_apis, str + ): + suggested_llm_apis = [suggested_llm_apis] schema: VolDictType = { vol.Optional( CONF_PROMPT, @@ -270,9 +269,8 @@ def openai_config_option_schema( ): TemplateSelector(), vol.Optional( CONF_LLM_HASS_API, - description={"suggested_value": options.get(CONF_LLM_HASS_API)}, - default="none", - ): SelectSelector(SelectSelectorConfig(options=hass_apis)), + description={"suggested_value": suggested_llm_apis}, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), vol.Required( CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) ): bool, diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 41abc504219..f022b4840eb 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -41,3 +41,12 @@ UNSUPPORTED_MODELS: list[str] = [ "gpt-4o-mini-realtime-preview", "gpt-4o-mini-realtime-preview-2024-12-17", ] + +WEB_SEARCH_MODELS: list[str] = [ + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4o", + "gpt-4o-search-preview", + "gpt-4o-mini", + "gpt-4o-mini-search-preview", +] diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 026e18f3ce1..67e79e270d7 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -2,7 +2,7 @@ from collections.abc import AsyncGenerator, Callable import json -from typing import Any, Literal +from typing import Any, Literal, cast import openai from openai._streaming import AsyncStream @@ -19,7 +19,11 @@ from openai.types.responses import ( ResponseIncompleteEvent, ResponseInputParam, ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, ResponseOutputMessage, + ResponseOutputMessageParam, + ResponseReasoningItem, + ResponseReasoningItemParam, ResponseStreamEvent, ResponseTextDeltaEvent, ToolParam, @@ -127,6 +131,7 @@ def _convert_content_to_param( async def _transform_stream( chat_log: conversation.ChatLog, result: AsyncStream[ResponseStreamEvent], + messages: ResponseInputParam, ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: """Transform an OpenAI delta stream into HA format.""" async for event in result: @@ -137,6 +142,15 @@ async def _transform_stream( yield {"role": event.item.role} elif isinstance(event.item, ResponseFunctionToolCall): current_tool_call = event.item + elif isinstance(event, ResponseOutputItemDoneEvent): + item = event.item.model_dump() + item.pop("status", None) + if isinstance(event.item, ResponseReasoningItem): + messages.append(cast(ResponseReasoningItemParam, item)) + elif isinstance(event.item, ResponseOutputMessage): + messages.append(cast(ResponseOutputMessageParam, item)) + elif isinstance(event.item, ResponseFunctionToolCall): + messages.append(cast(ResponseFunctionToolCallParam, item)) elif isinstance(event, ResponseTextDeltaEvent): yield {"content": event.delta} elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent): @@ -314,7 +328,6 @@ class OpenAIConversationEntity( "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), "user": chat_log.conversation_id, - "store": False, "stream": True, } if tools: @@ -326,6 +339,8 @@ class OpenAIConversationEntity( CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT ) } + else: + model_args["store"] = False try: result = await client.responses.create(**model_args) @@ -337,9 +352,10 @@ class OpenAIConversationEntity( raise HomeAssistantError("Error talking to OpenAI") from err async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, _transform_stream(chat_log, result) + user_input.agent_id, _transform_stream(chat_log, result, messages) ): - messages.extend(_convert_content_to_param(content)) + if not isinstance(content, conversation.AssistantContent): + messages.extend(_convert_content_to_param(content)) if not chat_log.unresponded_tool_results: break diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 988dd2321d5..84369eb15a2 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.68.2"] + "requirements": ["openai==1.76.2"] } diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index a373ec448d7..0a07fa354b2 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -40,22 +40,22 @@ }, "error": { "model_not_supported": "This model is not supported, please select a different model", - "web_search_not_supported": "Web search is only supported for gpt-4o and gpt-4o-mini models" + "web_search_not_supported": "Web search is not supported by this model" } }, "selector": { "reasoning_effort": { "options": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "search_context_size": { "options": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } }, @@ -89,7 +89,7 @@ }, "generate_content": { "name": "Generate content", - "description": "Sends a conversational query to ChatGPT including any attached image files", + "description": "Sends a conversational query to ChatGPT including any attached image or PDF files", "fields": { "config_entry": { "name": "Config entry", diff --git a/homeassistant/components/opensky/strings.json b/homeassistant/components/opensky/strings.json index 4b4dc908b14..c699783551f 100644 --- a/homeassistant/components/opensky/strings.json +++ b/homeassistant/components/opensky/strings.json @@ -15,7 +15,7 @@ "options": { "step": { "init": { - "description": "You can login to your OpenSky account to increase the update frequency.", + "description": "You can log in to your OpenSky account to increase the update frequency.", "data": { "radius": "[%key:component::opensky::config::step::user::data::radius%]", "altitude": "[%key:component::opensky::config::step::user::data::altitude%]", diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index c69151c293a..68463e764f2 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -2,9 +2,9 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass import logging -from types import MappingProxyType from typing import Any from pyotgw import vars as gw_vars @@ -94,7 +94,7 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): self, gw_hub: OpenThermGatewayHub, description: OpenThermClimateEntityDescription, - options: MappingProxyType[str, Any], + options: Mapping[str, Any], ) -> None: """Initialize the entity.""" super().__init__(gw_hub, description) diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index cc57a7d9e0c..ae1a1eb9276 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -172,8 +172,8 @@ "vcc": "Vcc (5V)", "led_e": "LED E", "led_f": "LED F", - "home": "Home", - "away": "Away", + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]", "ds1820": "DS1820", "dhw_block": "Block hot water" } diff --git a/homeassistant/components/openuv/strings.json b/homeassistant/components/openuv/strings.json index 9349d2cc116..f3b9aa686d5 100644 --- a/homeassistant/components/openuv/strings.json +++ b/homeassistant/components/openuv/strings.json @@ -54,10 +54,10 @@ "name": "Current UV level", "state": { "extreme": "Extreme", - "high": "High", - "low": "Low", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "Moderate", - "very_high": "Very high" + "very_high": "[%key:common::state::very_high%]" } }, "max_uv_index": { diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index e8b6dbf9718..d03c30b7db0 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta import logging -from typing import cast +from typing import Any, cast from opower import ( Account, @@ -30,7 +30,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import aiohttp_client +from homeassistant.helpers import aiohttp_client, issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -113,21 +113,69 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): _LOGGER.error("Error getting accounts: %s", err) raise for account in accounts: - id_prefix = "_".join( + id_prefix = ( ( - self.api.utility.subdomain(), - account.meter_type.name.lower(), - # Some utilities like AEP have "-" in their account id. - # Replace it with "_" to avoid "Invalid statistic_id" - account.utility_account_id.replace("-", "_").lower(), + f"{self.api.utility.subdomain()}_{account.meter_type.name}_" + f"{account.utility_account_id}" ) + # Some utilities like AEP have "-" in their account id. + # Other utilities like ngny-gas have "-" in their subdomain. + # Replace it with "_" to avoid "Invalid statistic_id" + .replace("-", "_") + .lower() ) cost_statistic_id = f"{DOMAIN}:{id_prefix}_energy_cost" + compensation_statistic_id = f"{DOMAIN}:{id_prefix}_energy_compensation" consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption" + return_statistic_id = f"{DOMAIN}:{id_prefix}_energy_return" _LOGGER.debug( - "Updating Statistics for %s and %s", + "Updating Statistics for %s, %s, %s, and %s", cost_statistic_id, + compensation_statistic_id, consumption_statistic_id, + return_statistic_id, + ) + + name_prefix = ( + f"Opower {self.api.utility.subdomain()} " + f"{account.meter_type.name.lower()} {account.utility_account_id}" + ) + cost_metadata = StatisticMetaData( + mean_type=StatisticMeanType.NONE, + has_sum=True, + name=f"{name_prefix} cost", + source=DOMAIN, + statistic_id=cost_statistic_id, + unit_of_measurement=None, + ) + compensation_metadata = StatisticMetaData( + mean_type=StatisticMeanType.NONE, + has_sum=True, + name=f"{name_prefix} compensation", + source=DOMAIN, + statistic_id=compensation_statistic_id, + unit_of_measurement=None, + ) + consumption_unit = ( + UnitOfEnergy.KILO_WATT_HOUR + if account.meter_type == MeterType.ELEC + else UnitOfVolume.CENTUM_CUBIC_FEET + ) + consumption_metadata = StatisticMetaData( + mean_type=StatisticMeanType.NONE, + has_sum=True, + name=f"{name_prefix} consumption", + source=DOMAIN, + statistic_id=consumption_statistic_id, + unit_of_measurement=consumption_unit, + ) + return_metadata = StatisticMetaData( + mean_type=StatisticMeanType.NONE, + has_sum=True, + name=f"{name_prefix} return", + source=DOMAIN, + statistic_id=return_statistic_id, + unit_of_measurement=consumption_unit, ) last_stat = await get_instance(self.hass).async_add_executor_job( @@ -139,9 +187,31 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): account, self.api.utility.timezone() ) cost_sum = 0.0 + compensation_sum = 0.0 consumption_sum = 0.0 + return_sum = 0.0 last_stats_time = None else: + migrated = await self._async_maybe_migrate_statistics( + account.utility_account_id, + { + cost_statistic_id: compensation_statistic_id, + consumption_statistic_id: return_statistic_id, + }, + { + cost_statistic_id: cost_metadata, + compensation_statistic_id: compensation_metadata, + consumption_statistic_id: consumption_metadata, + return_statistic_id: return_metadata, + }, + ) + if migrated: + # Skip update to avoid working on old data since the migration is done + # asynchronously. Update the statistics in the next refresh in 12h. + _LOGGER.debug( + "Statistics migration completed. Skipping update for now" + ) + continue cost_reads = await self._async_get_cost_reads( account, self.api.utility.timezone(), @@ -160,7 +230,12 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): self.hass, start, end, - {cost_statistic_id, consumption_statistic_id}, + { + cost_statistic_id, + compensation_statistic_id, + consumption_statistic_id, + return_statistic_id, + }, "hour", None, {"sum"}, @@ -175,53 +250,56 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): # We are in this code path only if get_last_statistics found a stat # so statistics_during_period should also have found at least one. assert stats - cost_sum = cast(float, stats[cost_statistic_id][0]["sum"]) - consumption_sum = cast(float, stats[consumption_statistic_id][0]["sum"]) + + def _safe_get_sum(records: list[Any]) -> float: + if records and "sum" in records[0]: + return float(records[0]["sum"]) + return 0.0 + + cost_sum = _safe_get_sum(stats.get(cost_statistic_id, [])) + compensation_sum = _safe_get_sum( + stats.get(compensation_statistic_id, []) + ) + consumption_sum = _safe_get_sum(stats.get(consumption_statistic_id, [])) + return_sum = _safe_get_sum(stats.get(return_statistic_id, [])) last_stats_time = stats[consumption_statistic_id][0]["start"] cost_statistics = [] + compensation_statistics = [] consumption_statistics = [] + return_statistics = [] for cost_read in cost_reads: start = cost_read.start_time if last_stats_time is not None and start.timestamp() <= last_stats_time: continue - cost_sum += cost_read.provided_cost - consumption_sum += cost_read.consumption + + cost_state = max(0, cost_read.provided_cost) + compensation_state = max(0, -cost_read.provided_cost) + consumption_state = max(0, cost_read.consumption) + return_state = max(0, -cost_read.consumption) + + cost_sum += cost_state + compensation_sum += compensation_state + consumption_sum += consumption_state + return_sum += return_state cost_statistics.append( + StatisticData(start=start, state=cost_state, sum=cost_sum) + ) + compensation_statistics.append( StatisticData( - start=start, state=cost_read.provided_cost, sum=cost_sum + start=start, state=compensation_state, sum=compensation_sum ) ) consumption_statistics.append( StatisticData( - start=start, state=cost_read.consumption, sum=consumption_sum + start=start, state=consumption_state, sum=consumption_sum ) ) - - name_prefix = ( - f"Opower {self.api.utility.subdomain()} " - f"{account.meter_type.name.lower()} {account.utility_account_id}" - ) - cost_metadata = StatisticMetaData( - mean_type=StatisticMeanType.NONE, - has_sum=True, - name=f"{name_prefix} cost", - source=DOMAIN, - statistic_id=cost_statistic_id, - unit_of_measurement=None, - ) - consumption_metadata = StatisticMetaData( - mean_type=StatisticMeanType.NONE, - has_sum=True, - name=f"{name_prefix} consumption", - source=DOMAIN, - statistic_id=consumption_statistic_id, - unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR - if account.meter_type == MeterType.ELEC - else UnitOfVolume.CENTUM_CUBIC_FEET, - ) + return_statistics.append( + StatisticData(start=start, state=return_state, sum=return_sum) + ) _LOGGER.debug( "Adding %s statistics for %s", @@ -229,6 +307,14 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): cost_statistic_id, ) async_add_external_statistics(self.hass, cost_metadata, cost_statistics) + _LOGGER.debug( + "Adding %s statistics for %s", + len(compensation_statistics), + compensation_statistic_id, + ) + async_add_external_statistics( + self.hass, compensation_metadata, compensation_statistics + ) _LOGGER.debug( "Adding %s statistics for %s", len(consumption_statistics), @@ -237,6 +323,135 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): async_add_external_statistics( self.hass, consumption_metadata, consumption_statistics ) + _LOGGER.debug( + "Adding %s statistics for %s", + len(return_statistics), + return_statistic_id, + ) + async_add_external_statistics(self.hass, return_metadata, return_statistics) + + async def _async_maybe_migrate_statistics( + self, + utility_account_id: str, + migration_map: dict[str, str], + metadata_map: dict[str, StatisticMetaData], + ) -> bool: + """Perform one-time statistics migration based on the provided map. + + Splits negative values from source IDs into target IDs. + + Args: + utility_account_id: The account ID (for issue_id). + migration_map: Map from source statistic ID to target statistic ID + (e.g., {cost_id: compensation_id}). + metadata_map: Map of all statistic IDs (source and target) to their metadata. + + """ + if not migration_map: + return False + + need_migration_source_ids = set() + for source_id, target_id in migration_map.items(): + last_target_stat = await get_instance(self.hass).async_add_executor_job( + get_last_statistics, + self.hass, + 1, + target_id, + True, + set(), + ) + if not last_target_stat: + need_migration_source_ids.add(source_id) + if not need_migration_source_ids: + return False + + _LOGGER.info("Starting one-time migration for: %s", need_migration_source_ids) + + processed_stats: dict[str, list[StatisticData]] = {} + + existing_stats = await get_instance(self.hass).async_add_executor_job( + statistics_during_period, + self.hass, + dt_util.utc_from_timestamp(0), + None, + need_migration_source_ids, + "hour", + None, + {"start", "state", "sum"}, + ) + for source_id, source_stats in existing_stats.items(): + _LOGGER.debug("Found %d statistics for %s", len(source_stats), source_id) + if not source_stats: + continue + target_id = migration_map[source_id] + + updated_source_stats: list[StatisticData] = [] + new_target_stats: list[StatisticData] = [] + updated_source_sum = 0.0 + new_target_sum = 0.0 + need_migration = False + + prev_sum = 0.0 + for stat in source_stats: + start = dt_util.utc_from_timestamp(stat["start"]) + curr_sum = cast(float, stat["sum"]) + state = curr_sum - prev_sum + prev_sum = curr_sum + if state < 0: + need_migration = True + + updated_source_state = max(0, state) + new_target_state = max(0, -state) + + updated_source_sum += updated_source_state + new_target_sum += new_target_state + + updated_source_stats.append( + StatisticData( + start=start, state=updated_source_state, sum=updated_source_sum + ) + ) + new_target_stats.append( + StatisticData( + start=start, state=new_target_state, sum=new_target_sum + ) + ) + + if need_migration: + processed_stats[source_id] = updated_source_stats + processed_stats[target_id] = new_target_stats + else: + need_migration_source_ids.remove(source_id) + + if not need_migration_source_ids: + _LOGGER.debug("No migration needed") + return False + + for stat_id, stats in processed_stats.items(): + _LOGGER.debug("Applying %d migrated stats for %s", len(stats), stat_id) + async_add_external_statistics(self.hass, metadata_map[stat_id], stats) + + ir.async_create_issue( + self.hass, + DOMAIN, + issue_id=f"return_to_grid_migration_{utility_account_id}", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="return_to_grid_migration", + translation_placeholders={ + "utility_account_id": utility_account_id, + "energy_settings": "/config/energy", + "target_ids": "\n".join( + { + str(metadata_map[v]["name"]) + for k, v in migration_map.items() + if k in need_migration_source_ids + } + ), + }, + ) + + return True async def _async_get_cost_reads( self, account: Account, time_zone_str: str, start_time: float | None = None diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 2cc942363cf..a09405f1ca8 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.11.1"] + "requirements": ["opower==0.12.0"] } diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index 749545743fe..f65aeb011ee 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -31,5 +31,11 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "issues": { + "return_to_grid_migration": { + "title": "Return to grid statistics for account: {utility_account_id}", + "description": "We found negative values in your existing consumption statistics, likely because you have solar. We split those in separate return statistics for a better experience in the Energy dashboard.\n\nPlease visit the [Energy configuration page]({energy_settings}) to add the following statistics in the **Return to grid** section:\n\n{target_ids}\n\nOnce you have added them, ignore this issue." + } } } diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json index 7e10168d941..465f3f15c6b 100644 --- a/homeassistant/components/osoenergy/strings.json +++ b/homeassistant/components/osoenergy/strings.json @@ -55,12 +55,12 @@ "heater_mode": { "name": "Heater mode", "state": { - "auto": "Auto", + "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]", "extraenergy": "Extra energy", "ffr": "Fast frequency reserve", "legionella": "Legionella", - "manual": "Manual", - "off": "Off", "powersave": "Power save", "voltage": "Voltage" } @@ -70,7 +70,7 @@ "state": { "advanced": "Advanced", "gridcompany": "Grid company", - "off": "Off", + "off": "[%key:common::state::off%]", "oso": "OSO", "smartcompany": "Smart company" } diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index 8aa1ed0e4fe..c9bf618ee8f 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -12,6 +12,7 @@ from pyoverkiz.enums import APIType, OverkizState, UIClass, UIWidget from pyoverkiz.exceptions import ( BadCredentialsException, MaintenanceException, + NotAuthenticatedException, NotSuchTokenException, TooManyRequestsException, ) @@ -92,7 +93,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) scenarios = await client.get_scenarios() else: scenarios = [] - except (BadCredentialsException, NotSuchTokenException) as exception: + except ( + BadCredentialsException, + NotSuchTokenException, + NotAuthenticatedException, + ) as exception: raise ConfigEntryAuthFailed("Invalid authentication") from exception except TooManyRequestsException as exception: raise ConfigEntryNotReady("Too many requests, try again later") from exception diff --git a/homeassistant/components/overkiz/binary_sensor.py b/homeassistant/components/overkiz/binary_sensor.py index 09319d59932..5db96e17322 100644 --- a/homeassistant/components/overkiz/binary_sensor.py +++ b/homeassistant/components/overkiz/binary_sensor.py @@ -49,6 +49,7 @@ BINARY_SENSOR_DESCRIPTIONS: list[OverkizBinarySensorDescription] = [ key=OverkizState.CORE_WATER_DETECTION, name="Water", icon="mdi:water", + device_class=BinarySensorDeviceClass.MOISTURE, value_fn=lambda state: state == OverkizCommandParam.DETECTED, ), # AirSensor/AirFlowSensor diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py index 059e64ef55d..4a05a94b635 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py @@ -58,9 +58,12 @@ class AtlanticElectricalHeater(OverkizEntity, ClimateEntity): @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" - return OVERKIZ_TO_HVAC_MODES[ - cast(str, self.executor.select_state(OverkizState.CORE_ON_OFF)) - ] + if OverkizState.CORE_ON_OFF in self.device.states: + return OVERKIZ_TO_HVAC_MODES[ + cast(str, self.executor.select_state(OverkizState.CORE_ON_OFF)) + ] + + return HVACMode.OFF async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py index 93c7d03293b..041571f7b5f 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py @@ -13,6 +13,7 @@ from homeassistant.components.climate import ( PRESET_NONE, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature @@ -56,6 +57,12 @@ OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { OverkizCommandParam.INTERNAL: HVACMode.AUTO, } +OVERKIZ_TO_HVAC_ACTION: dict[str, HVACAction] = { + OverkizCommandParam.STANDBY: HVACAction.IDLE, + OverkizCommandParam.INCREASE: HVACAction.HEATING, + OverkizCommandParam.NONE: HVACAction.OFF, +} + HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()} TEMPERATURE_SENSOR_DEVICE_INDEX = 2 @@ -102,6 +109,14 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( OverkizCommand.SET_OPERATING_MODE, HVAC_MODE_TO_OVERKIZ[hvac_mode] ) + @property + def hvac_action(self) -> HVACAction: + """Return the current running hvac operation ie. heating, idle, off.""" + states = self.device.states + if (state := states[OverkizState.CORE_REGULATION_MODE]) and state.value_as_str: + return OVERKIZ_TO_HVAC_ACTION[state.value_as_str] + return HVACAction.OFF + @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py index 0b5ba3ffcc7..e0cfebc2449 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py @@ -20,11 +20,13 @@ from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity PRESET_DRYING = "drying" +PRESET_PROG = "prog" OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { OverkizCommandParam.EXTERNAL: HVACMode.HEAT, # manu - OverkizCommandParam.INTERNAL: HVACMode.AUTO, # prog - OverkizCommandParam.STANDBY: HVACMode.OFF, + OverkizCommandParam.INTERNAL: HVACMode.AUTO, # prog (schedule, user program) - mapped as preset + OverkizCommandParam.AUTO: HVACMode.AUTO, # auto (intelligent, user behavior) + OverkizCommandParam.STANDBY: HVACMode.OFF, # off } HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()} @@ -33,7 +35,6 @@ OVERKIZ_TO_PRESET_MODE: dict[str, str] = { OverkizCommandParam.BOOST: PRESET_BOOST, OverkizCommandParam.DRYING: PRESET_DRYING, } - PRESET_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODE.items()} TEMPERATURE_SENSOR_DEVICE_INDEX = 7 @@ -43,9 +44,15 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): """Representation of Atlantic Electrical Towel Dryer.""" _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] - _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] + _attr_preset_modes = [PRESET_NONE, PRESET_PROG] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.PRESET_MODE + ) def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator @@ -56,15 +63,15 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): TEMPERATURE_SENSOR_DEVICE_INDEX ) - self._attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - - # Not all AtlanticElectricalTowelDryer models support presets, thus we need to check if the command is available + # Not all AtlanticElectricalTowelDryer models support temporary presets, + # thus we check if the command is available and then extend the presets if self.executor.has_command(OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE): - self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE + # Extend preset modes with supported temporary presets, avoiding duplicates + self._attr_preset_modes += [ + mode + for mode in PRESET_MODE_TO_OVERKIZ + if mode not in self._attr_preset_modes + ] @property def hvac_mode(self) -> HVACMode: @@ -120,16 +127,53 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - return OVERKIZ_TO_PRESET_MODE[ - cast( - str, - self.executor.select_state(OverkizState.IO_TOWEL_DRYER_TEMPORARY_STATE), - ) - ] + if ( + OverkizState.CORE_OPERATING_MODE in self.device.states + and cast(str, self.executor.select_state(OverkizState.CORE_OPERATING_MODE)) + == OverkizCommandParam.INTERNAL + ): + return PRESET_PROG + + if PRESET_DRYING in self._attr_preset_modes: + return OVERKIZ_TO_PRESET_MODE[ + cast( + str, + self.executor.select_state( + OverkizState.IO_TOWEL_DRYER_TEMPORARY_STATE + ), + ) + ] + + return PRESET_NONE async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - await self.executor.async_execute_command( - OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE, - PRESET_MODE_TO_OVERKIZ[preset_mode], - ) + # If the preset mode is set to prog, we need to set the operating mode to internal + if preset_mode == PRESET_PROG: + # If currently in a temporary preset (drying or boost), turn it off before turn on prog + if self.preset_mode in (PRESET_DRYING, PRESET_BOOST): + await self.executor.async_execute_command( + OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE, + OverkizCommandParam.PERMANENT_HEATING, + ) + + await self.executor.async_execute_command( + OverkizCommand.SET_TOWEL_DRYER_OPERATING_MODE, + OverkizCommandParam.INTERNAL, + ) + + # If the preset mode is set from prog to none, we need to set the operating mode to external + # This will set the towel dryer to auto (intelligent mode) + elif preset_mode == PRESET_NONE and self.preset_mode == PRESET_PROG: + await self.executor.async_execute_command( + OverkizCommand.SET_TOWEL_DRYER_OPERATING_MODE, + OverkizCommandParam.AUTO, + ) + + # Normal behavior of setting a preset mode + # for towel dryers that support temporary presets + elif PRESET_DRYING in self._attr_preset_modes: + await self.executor.async_execute_command( + OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE, + PRESET_MODE_TO_OVERKIZ[preset_mode], + ) diff --git a/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py b/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py index 5ca17f9b6b1..381ec4d83ba 100644 --- a/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py @@ -77,7 +77,7 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] + _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ, HVACMode.OFF] _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] # Both min and max temp values have been retrieved from the Somfy Application. _attr_min_temp = 15.0 @@ -110,9 +110,14 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - await self.executor.async_execute_command( - OverkizCommand.SET_ACTIVE_MODE, HVAC_MODES_TO_OVERKIZ[hvac_mode] - ) + if hvac_mode is HVACMode.OFF: + await self.executor.async_execute_command( + OverkizCommand.SET_ON_OFF, OverkizCommandParam.OFF + ) + else: + await self.executor.async_execute_command( + OverkizCommand.SET_ACTIVE_MODE, HVAC_MODES_TO_OVERKIZ[hvac_mode] + ) @property def preset_mode(self) -> str | None: diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index af955e5fb95..520e9460147 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -13,12 +13,12 @@ from pyoverkiz.exceptions import ( BadCredentialsException, CozyTouchBadCredentialsException, MaintenanceException, + NotAuthenticatedException, NotSuchTokenException, TooManyAttemptsBannedException, TooManyRequestsException, UnknownUserException, ) -from pyoverkiz.models import OverkizServer from pyoverkiz.obfuscate import obfuscate_id from pyoverkiz.utils import generate_local_server, is_overkiz_gateway import voluptuous as vol @@ -31,7 +31,6 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) -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 @@ -39,15 +38,12 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_API_TYPE, CONF_HUB, DEFAULT_SERVER, DOMAIN, LOGGER -class DeveloperModeDisabled(HomeAssistantError): - """Error to indicate Somfy Developer Mode is disabled.""" - - class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Overkiz (by Somfy).""" VERSION = 1 + _verify_ssl: bool = True _api_type: APIType = APIType.CLOUD _user: str | None = None _server: str = DEFAULT_SERVER @@ -57,27 +53,36 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): """Validate user credentials.""" user_input[CONF_API_TYPE] = self._api_type - client = self._create_cloud_client( - username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], - server=SUPPORTED_SERVERS[user_input[CONF_HUB]], - ) - await client.login(register_event_listener=False) - - # For Local API, we create and activate a local token if self._api_type == APIType.LOCAL: - user_input[CONF_TOKEN] = await self._create_local_api_token( - cloud_client=client, - host=user_input[CONF_HOST], + user_input[CONF_VERIFY_SSL] = self._verify_ssl + session = async_create_clientsession( + self.hass, verify_ssl=user_input[CONF_VERIFY_SSL] + ) + client = OverkizClient( + username="", + password="", + token=user_input[CONF_TOKEN], + session=session, + server=generate_local_server(host=user_input[CONF_HOST]), verify_ssl=user_input[CONF_VERIFY_SSL], ) + else: # APIType.CLOUD + session = async_create_clientsession(self.hass) + client = OverkizClient( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + server=SUPPORTED_SERVERS[user_input[CONF_HUB]], + session=session, + ) + + await client.login(register_event_listener=False) # Set main gateway id as unique id if gateways := await client.get_gateways(): for gateway in gateways: if is_overkiz_gateway(gateway.id): - gateway_id = gateway.id - await self.async_set_unique_id(gateway_id, raise_on_progress=False) + await self.async_set_unique_id(gateway.id, raise_on_progress=False) + break return user_input @@ -141,15 +146,13 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: self._user = user_input[CONF_USERNAME] - - # inherit the server from previous step user_input[CONF_HUB] = self._server try: await self.async_validate_input(user_input) except TooManyRequestsException: errors["base"] = "too_many_requests" - except BadCredentialsException as exception: + except (BadCredentialsException, NotAuthenticatedException) as exception: # If authentication with CozyTouch auth server is valid, but token is invalid # for Overkiz API server, the hardware is not supported. if user_input[CONF_HUB] in { @@ -211,16 +214,18 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: self._host = user_input[CONF_HOST] - self._user = user_input[CONF_USERNAME] - - # inherit the server from previous step + self._verify_ssl = user_input[CONF_VERIFY_SSL] user_input[CONF_HUB] = self._server try: user_input = await self.async_validate_input(user_input) except TooManyRequestsException: errors["base"] = "too_many_requests" - except BadCredentialsException: + except ( + BadCredentialsException, + NotSuchTokenException, + NotAuthenticatedException, + ): errors["base"] = "invalid_auth" except ClientConnectorCertificateError as exception: errors["base"] = "certificate_verify_failed" @@ -232,10 +237,6 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "server_in_maintenance" except TooManyAttemptsBannedException: errors["base"] = "too_many_attempts" - except NotSuchTokenException: - errors["base"] = "no_such_token" - except DeveloperModeDisabled: - errors["base"] = "developer_mode_disabled" except UnknownUserException: # Somfy Protect accounts are not supported since they don't use # the Overkiz API server. Login will return unknown user. @@ -264,9 +265,8 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required(CONF_HOST, default=self._host): str, - vol.Required(CONF_USERNAME, default=self._user): str, - vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_VERIFY_SSL, default=True): bool, + vol.Required(CONF_TOKEN): str, + vol.Required(CONF_VERIFY_SSL, default=self._verify_ssl): bool, } ), description_placeholders=description_placeholders, @@ -320,64 +320,15 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauth.""" - # overkiz entries always have unique IDs + # Overkiz entries always have unique IDs self.context["title_placeholders"] = {"gateway_id": cast(str, self.unique_id)} - - self._user = entry_data[CONF_USERNAME] - self._server = entry_data[CONF_HUB] self._api_type = entry_data.get(CONF_API_TYPE, APIType.CLOUD) + self._server = entry_data[CONF_HUB] if self._api_type == APIType.LOCAL: self._host = entry_data[CONF_HOST] + self._verify_ssl = entry_data[CONF_VERIFY_SSL] + else: + self._user = entry_data[CONF_USERNAME] return await self.async_step_user(dict(entry_data)) - - def _create_cloud_client( - self, username: str, password: str, server: OverkizServer - ) -> OverkizClient: - session = async_create_clientsession(self.hass) - return OverkizClient( - username=username, password=password, server=server, session=session - ) - - async def _create_local_api_token( - self, cloud_client: OverkizClient, host: str, verify_ssl: bool - ) -> str: - """Create local API token.""" - # Create session on Somfy cloud server to generate an access token for local API - gateways = await cloud_client.get_gateways() - - gateway_id = "" - for gateway in gateways: - # Overkiz can return multiple gateways, but we only can generate a token - # for the main gateway. - if is_overkiz_gateway(gateway.id): - gateway_id = gateway.id - - developer_mode = await cloud_client.get_setup_option( - f"developerMode-{gateway_id}" - ) - - if developer_mode is None: - raise DeveloperModeDisabled - - token = await cloud_client.generate_local_token(gateway_id) - await cloud_client.activate_local_token( - gateway_id=gateway_id, token=token, label="Home Assistant/local" - ) - - session = async_create_clientsession(self.hass, verify_ssl=verify_ssl) - - # Local API - local_client = OverkizClient( - username="", - password="", - token=token, - session=session, - server=generate_local_server(host=host), - verify_ssl=verify_ssl, - ) - - await local_client.login() - - return token diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index 4b79cfc9c06..598bf4b06d0 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -79,7 +79,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): """Fetch Overkiz data via event listener.""" try: events = await self.client.fetch_events() - except BadCredentialsException as exception: + except (BadCredentialsException, NotAuthenticatedException) as exception: raise ConfigEntryAuthFailed("Invalid authentication.") from exception except TooManyConcurrentRequestsException as exception: raise UpdateFailed("Too many concurrent requests.") from exception @@ -98,7 +98,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): try: await self.client.login() self.devices = await self._get_devices() - except BadCredentialsException as exception: + except (BadCredentialsException, NotAuthenticatedException) as exception: raise ConfigEntryAuthFailed("Invalid authentication.") from exception except TooManyRequestsException as exception: raise UpdateFailed("Too many requests, try again later.") from exception diff --git a/homeassistant/components/overkiz/entity.py b/homeassistant/components/overkiz/entity.py index c13b2fc96ba..d3f49b20f08 100644 --- a/homeassistant/components/overkiz/entity.py +++ b/homeassistant/components/overkiz/entity.py @@ -35,7 +35,6 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]): self.executor = OverkizExecutor(device_url, coordinator) self._attr_assumed_state = not self.device.states - self._attr_available = self.device.available self._attr_unique_id = self.device.device_url if self.is_sub_device: @@ -44,6 +43,11 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]): self._attr_device_info = self.generate_device_info() + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.device.available and super().available + @property def is_sub_device(self) -> bool: """Return True if device is a sub device.""" diff --git a/homeassistant/components/overkiz/icons.json b/homeassistant/components/overkiz/icons.json new file mode 100644 index 00000000000..3347750063e --- /dev/null +++ b/homeassistant/components/overkiz/icons.json @@ -0,0 +1,46 @@ +{ + "entity": { + "climate": { + "overkiz": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "mdi:thermostat-auto", + "comfort-1": "mdi:thermometer", + "comfort-2": "mdi:thermometer-low", + "drying": "mdi:hair-dryer", + "frost_protection": "mdi:snowflake", + "prog": "mdi:clock-outline", + "external": "mdi:remote" + } + } + } + } + }, + "select": { + "open_closed_pedestrian": { + "default": "mdi:content-save-cog" + }, + "open_closed_partial": { + "default": "mdi:content-save-cog" + }, + "memorized_simple_volume": { + "default": "mdi:volume-medium", + "state": { + "highest": "mdi:volume-high", + "standard": "mdi:volume-medium" + } + }, + "operating_mode": { + "default": "mdi:sun-snowflake", + "state": { + "heating": "mdi:heat-wave", + "cooling": "mdi:snowflake" + } + }, + "active_zones": { + "default": "mdi:shield-lock" + } + } + } +} diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 937b4ccb937..6f1af6d5aca 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.16.5"], + "requirements": ["pyoverkiz==1.17.1"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index 83c0e7cf7a8..70028f138b7 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -14,7 +14,7 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.const import EntityCategory, UnitOfTemperature +from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -172,6 +172,8 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ native_max_value=7, set_native_value=_async_set_native_value_boost_mode_duration, entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, ), # DomesticHotWaterProduction - away mode in days (0 - 6) OverkizNumberDescription( @@ -182,6 +184,8 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ native_min_value=0, native_max_value=6, entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, ), ] diff --git a/homeassistant/components/overkiz/select.py b/homeassistant/components/overkiz/select.py index e23dafdaab8..d93b71b540f 100644 --- a/homeassistant/components/overkiz/select.py +++ b/homeassistant/components/overkiz/select.py @@ -72,7 +72,6 @@ SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ OverkizSelectDescription( key=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, name="Position", - icon="mdi:content-save-cog", options=[ OverkizCommandParam.OPEN, OverkizCommandParam.PEDESTRIAN, @@ -84,7 +83,6 @@ SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ OverkizSelectDescription( key=OverkizState.CORE_OPEN_CLOSED_PARTIAL, name="Position", - icon="mdi:content-save-cog", options=[ OverkizCommandParam.OPEN, OverkizCommandParam.PARTIAL, @@ -96,7 +94,6 @@ SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ OverkizSelectDescription( key=OverkizState.IO_MEMORIZED_SIMPLE_VOLUME, name="Memorized simple volume", - icon="mdi:volume-high", options=[OverkizCommandParam.STANDARD, OverkizCommandParam.HIGHEST], select_option=_select_option_memorized_simple_volume, entity_category=EntityCategory.CONFIG, @@ -106,20 +103,20 @@ SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ OverkizSelectDescription( key=OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_OPERATING_MODE, name="Operating mode", - icon="mdi:sun-snowflake", options=[OverkizCommandParam.HEATING, OverkizCommandParam.COOLING], select_option=lambda option, execute_command: execute_command( OverkizCommand.SET_OPERATING_MODE, option ), entity_category=EntityCategory.CONFIG, + translation_key="operating_mode", ), # StatefulAlarmController OverkizSelectDescription( key=OverkizState.CORE_ACTIVE_ZONES, name="Active zones", - icon="mdi:shield-lock", options=["", "A", "B", "C", "A,B", "B,C", "A,C", "A,B,C"], select_option=_select_option_active_zone, + translation_key="active_zones", ), ] diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index cec0d0d2571..b0a15b3970e 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -23,6 +23,7 @@ from homeassistant.const import ( EntityCategory, UnitOfEnergy, UnitOfPower, + UnitOfSpeed, UnitOfTemperature, UnitOfTime, UnitOfVolume, @@ -126,6 +127,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ name="Outlet engine", icon="mdi:fan-chevron-down", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, state_class=SensorStateClass.MEASUREMENT, ), OverkizSensorDescription( @@ -152,14 +154,23 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ OverkizSensorDescription( key=OverkizState.CORE_FOSSIL_ENERGY_CONSUMPTION, name="Fossil energy consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_GAS_CONSUMPTION, name="Gas consumption", + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_THERMAL_ENERGY_CONSUMPTION, name="Thermal energy consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), # LightSensor/LuminanceSensor OverkizSensorDescription( @@ -204,7 +215,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF2, @@ -213,7 +224,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF3, @@ -222,7 +233,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF4, @@ -231,7 +242,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF5, @@ -240,7 +251,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF6, @@ -249,7 +260,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF7, @@ -258,7 +269,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF8, @@ -267,7 +278,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF9, @@ -276,7 +287,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), # HumiditySensor/RelativeHumiditySensor OverkizSensorDescription( @@ -342,6 +353,8 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ name="Sun energy", native_value=lambda value: round(cast(float, value), 2), icon="mdi:solar-power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), # WindSensor/WindSpeedSensor @@ -350,6 +363,8 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ name="Wind speed", native_value=lambda value: round(cast(float, value), 2), icon="mdi:weather-windy", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, ), # SmokeSensor/SmokeSensor @@ -398,6 +413,8 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ native_value=lambda value: OVERKIZ_STATE_TO_TRANSLATION.get( cast(str, value), cast(str, value) ), + device_class=SensorDeviceClass.ENUM, + options=["dead", "low_battery", "maintenance_required", "no_defect"], ), # DomesticHotWaterProduction/WaterHeatingSystem OverkizSensorDescription( diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 05b5eac4b21..c8f0fae3622 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -32,17 +32,15 @@ } }, "local": { - "description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\nAfter activation, enter your application credentials and change the host to include your Gateway PIN or enter the IP address of your gateway.", + "description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\n1. Open the TaHoma By Somfy application on your device.\n2. Navigate to the Help & advanced features -> Advanced features menu in the application.\n3. Activate Developer Mode by tapping 7 times on the version number of your gateway (like 2025.1.4-11).\n4. Generate a token from the Developer Mode menu to authenticate your API calls.\n\n5. Enter the generated token below and update the host to include your Gateway PIN or the IP address of your gateway.", "data": { "host": "[%key:common::config_flow::data::host%]", - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]", + "token": "[%key:common::config_flow::data::api_token%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { "host": "The hostname or IP address of your Overkiz hub.", - "username": "The username of your cloud account (app).", - "password": "The password of your cloud account (app).", + "token": "Token generated by the app used to control your device.", "verify_ssl": "Verify the SSL certificate. Select this only if you are connecting via the hostname." } } @@ -71,14 +69,14 @@ "state_attributes": { "preset_mode": { "state": { - "auto": "Auto", - "comfort-1": "Comfort 1", - "comfort-2": "Comfort 2", + "auto": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]", + "comfort-1": "Comfort -1°C", + "comfort-2": "Comfort -2°C", "drying": "Drying", "external": "External", "freeze": "Freeze", "frost_protection": "Frost protection", - "manual": "Manual", "night": "Night", "prog": "Prog" } @@ -114,16 +112,22 @@ "highest": "Highest", "standard": "Standard" } + }, + "operating_mode": { + "state": { + "heating": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::heating%]", + "cooling": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::cooling%]" + } } }, "sensor": { "battery": { "state": { "full": "Full", - "low": "Low", - "normal": "Normal", - "medium": "Medium", - "verylow": "Very low", + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]", + "medium": "[%key:common::state::medium%]", + "verylow": "[%key:common::state::very_low%]", "good": "Good", "critical": "Critical" } @@ -131,9 +135,9 @@ "discrete_rssi_level": { "state": { "good": "Good", - "low": "Low", - "normal": "Normal", - "verylow": "Very low" + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]", + "verylow": "[%key:common::state::very_low%]" } }, "priority_lock_originator": { diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json index 501ee777fe9..59a2ba1ffe9 100644 --- a/homeassistant/components/palazzetti/strings.json +++ b/homeassistant/components/palazzetti/strings.json @@ -52,8 +52,8 @@ "fan_mode": { "state": { "silent": "Silent", - "auto": "Auto", - "high": "High" + "auto": "[%key:common::state::auto%]", + "high": "[%key:common::state::high%]" } } } @@ -74,7 +74,7 @@ "status": { "name": "Status", "state": { - "off": "Off", + "off": "[%key:common::state::off%]", "off_timer": "Timer-regulated switch off", "test_fire": "Ignition test", "heatup": "Pellet feed", @@ -83,7 +83,7 @@ "burning": "Operating", "burning_mod": "Operating - Modulating", "unknown": "Unknown", - "cool_fluid": "Stand-by", + "cool_fluid": "[%key:common::state::standby%]", "fire_stop": "Switch off", "clean_fire": "Burn pot cleaning", "cooling": "Cooling in progress", diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index 416f1a2c062..d13add0c2dd 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -108,8 +108,8 @@ "name": "State", "state": { "charging": "[%key:common::state::charging%]", - "error": "Error", - "fault": "Fault", + "error": "[%key:common::state::error%]", + "fault": "[%key:common::state::fault%]", "invalid": "Invalid", "no_ev_connected": "No EV connected", "suspended": "Suspended" diff --git a/homeassistant/components/pegel_online/entity.py b/homeassistant/components/pegel_online/entity.py index 4e157a5f63b..d69b0e13667 100644 --- a/homeassistant/components/pegel_online/entity.py +++ b/homeassistant/components/pegel_online/entity.py @@ -13,18 +13,13 @@ class PegelOnlineEntity(CoordinatorEntity[PegelOnlineDataUpdateCoordinator]): """Representation of a PEGELONLINE entity.""" _attr_has_entity_name = True - _attr_available = True def __init__(self, coordinator: PegelOnlineDataUpdateCoordinator) -> None: """Initialize a PEGELONLINE entity.""" super().__init__(coordinator) self.station = coordinator.station self._attr_extra_state_attributes = {} - - @property - def device_info(self) -> DeviceInfo: - """Return the device information of the entity.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.station.uuid)}, name=f"{self.station.name} {self.station.water_name}", manufacturer=self.station.agency, diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index fd90683a9b2..ee2e6750911 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -2,9 +2,10 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from aiopegelonline.models import CurrentMeasurement +from aiopegelonline.models import CurrentMeasurement, StationMeasurements from homeassistant.components.sensor import ( SensorDeviceClass, @@ -24,67 +25,68 @@ from .entity import PegelOnlineEntity class PegelOnlineSensorEntityDescription(SensorEntityDescription): """PEGELONLINE sensor entity description.""" - measurement_key: str + measurement_fn: Callable[[StationMeasurements], CurrentMeasurement | None] SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( PegelOnlineSensorEntityDescription( key="air_temperature", translation_key="air_temperature", - measurement_key="air_temperature", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, + measurement_fn=lambda data: data.air_temperature, ), PegelOnlineSensorEntityDescription( key="clearance_height", translation_key="clearance_height", - measurement_key="clearance_height", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DISTANCE, + measurement_fn=lambda data: data.clearance_height, ), PegelOnlineSensorEntityDescription( key="oxygen_level", translation_key="oxygen_level", - measurement_key="oxygen_level", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + measurement_fn=lambda data: data.oxygen_level, ), PegelOnlineSensorEntityDescription( key="ph_value", - measurement_key="ph_value", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.PH, entity_registry_enabled_default=False, + measurement_fn=lambda data: data.ph_value, ), PegelOnlineSensorEntityDescription( key="water_speed", translation_key="water_speed", - measurement_key="water_speed", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.SPEED, entity_registry_enabled_default=False, + measurement_fn=lambda data: data.water_speed, ), PegelOnlineSensorEntityDescription( key="water_flow", translation_key="water_flow", - measurement_key="water_flow", state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, entity_registry_enabled_default=False, + measurement_fn=lambda data: data.water_flow, ), PegelOnlineSensorEntityDescription( key="water_level", translation_key="water_level", - measurement_key="water_level", state_class=SensorStateClass.MEASUREMENT, + measurement_fn=lambda data: data.water_level, ), PegelOnlineSensorEntityDescription( key="water_temperature", translation_key="water_temperature", - measurement_key="water_temperature", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, + measurement_fn=lambda data: data.water_temperature, ), ) @@ -101,7 +103,7 @@ async def async_setup_entry( [ PegelOnlineSensor(coordinator, description) for description in SENSORS - if getattr(coordinator.data, description.measurement_key) is not None + if description.measurement_fn(coordinator.data) is not None ] ) @@ -135,7 +137,9 @@ class PegelOnlineSensor(PegelOnlineEntity, SensorEntity): @property def measurement(self) -> CurrentMeasurement: """Return the measurement data of the entity.""" - return getattr(self.coordinator.data, self.entity_description.measurement_key) + measurement = self.entity_description.measurement_fn(self.coordinator.data) + assert measurement is not None # we ensure existence in async_setup_entry + return measurement @property def native_value(self) -> float: diff --git a/homeassistant/components/pegel_online/strings.json b/homeassistant/components/pegel_online/strings.json index b8d18e63a4f..7d0702754af 100644 --- a/homeassistant/components/pegel_online/strings.json +++ b/homeassistant/components/pegel_online/strings.json @@ -2,10 +2,10 @@ "config": { "step": { "user": { - "description": "Select the area, where you want to search for water measuring stations", + "description": "Select the area in which you want to search for water measuring stations", "data": { "location": "[%key:common::config_flow::data::location%]", - "radius": "Search radius (in km)" + "radius": "Search radius" } }, "select_station": { diff --git a/homeassistant/components/pglab/__init__.py b/homeassistant/components/pglab/__init__.py index 8bce7be26e8..a490f476f83 100644 --- a/homeassistant/components/pglab/__init__.py +++ b/homeassistant/components/pglab/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations from pypglab.mqtt import ( Client as PyPGLabMqttClient, Sub_State as PyPGLabSubState, - Subcribe_CallBack as PyPGLabSubscribeCallBack, + Subscribe_CallBack as PyPGLabSubscribeCallBack, ) from homeassistant.components import mqtt diff --git a/homeassistant/components/pglab/coordinator.py b/homeassistant/components/pglab/coordinator.py index 53c5dbc3b58..b703f368eb1 100644 --- a/homeassistant/components/pglab/coordinator.py +++ b/homeassistant/components/pglab/coordinator.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any from pypglab.const import SENSOR_REBOOT_TIME, SENSOR_TEMPERATURE, SENSOR_VOLTAGE from pypglab.device import Device as PyPGLabDevice -from pypglab.sensor import Sensor as PyPGLabSensors +from pypglab.sensor import StatusSensor as PyPGLabSensors from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -31,7 +31,7 @@ class PGLabSensorsCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Initialize.""" # get a reference of PG Lab device internal sensors state - self._sensors: PyPGLabSensors = pglab_device.sensors + self._sensors: PyPGLabSensors = pglab_device.status_sensor super().__init__( hass, diff --git a/homeassistant/components/pglab/discovery.py b/homeassistant/components/pglab/discovery.py index c1d8653c17b..c83ea4466fa 100644 --- a/homeassistant/components/pglab/discovery.py +++ b/homeassistant/components/pglab/discovery.py @@ -220,7 +220,7 @@ class PGLabDiscovery: configuration_url=f"http://{pglab_device.ip}/", connections={(CONNECTION_NETWORK_MAC, pglab_device.mac)}, identifiers={(DOMAIN, pglab_device.id)}, - manufacturer=pglab_device.manufactor, + manufacturer=pglab_device.manufacturer, model=pglab_device.type, name=pglab_device.name, sw_version=pglab_device.firmware_version, diff --git a/homeassistant/components/pglab/entity.py b/homeassistant/components/pglab/entity.py index 59a4e28de89..c0a02f4f835 100644 --- a/homeassistant/components/pglab/entity.py +++ b/homeassistant/components/pglab/entity.py @@ -37,7 +37,7 @@ class PGLabBaseEntity(Entity): sw_version=pglab_device.firmware_version, hw_version=pglab_device.hardware_version, model=pglab_device.type, - manufacturer=pglab_device.manufactor, + manufacturer=pglab_device.manufacturer, configuration_url=f"http://{pglab_device.ip}/", connections={(CONNECTION_NETWORK_MAC, pglab_device.mac)}, ) diff --git a/homeassistant/components/pglab/manifest.json b/homeassistant/components/pglab/manifest.json index 7f7d596be77..c8dca6c6229 100644 --- a/homeassistant/components/pglab/manifest.json +++ b/homeassistant/components/pglab/manifest.json @@ -9,6 +9,6 @@ "loggers": ["pglab"], "mqtt": ["pglab/discovery/#"], "quality_scale": "bronze", - "requirements": ["pypglab==0.0.3"], + "requirements": ["pypglab==0.0.5"], "single_config_entry": true } diff --git a/homeassistant/components/picotts/tts.py b/homeassistant/components/picotts/tts.py index 44d33145b3d..54caf1a2b26 100644 --- a/homeassistant/components/picotts/tts.py +++ b/homeassistant/components/picotts/tts.py @@ -56,10 +56,15 @@ class PicoProvider(Provider): with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmpf: fname = tmpf.name - cmd = ["pico2wave", "--wave", fname, "-l", language, "--", message] - subprocess.call(cmd) + cmd = ["pico2wave", "--wave", fname, "-l", language] + result = subprocess.run(cmd, text=True, input=message, check=False) data = None try: + if result.returncode != 0: + _LOGGER.error( + "Error running pico2wave, return code: %s", result.returncode + ) + return (None, None) with open(fname, "rb") as voice: data = voice.read() except OSError: diff --git a/homeassistant/components/pitsos/__init__.py b/homeassistant/components/pitsos/__init__.py new file mode 100644 index 00000000000..e49539d8ed2 --- /dev/null +++ b/homeassistant/components/pitsos/__init__.py @@ -0,0 +1 @@ +"""Pitsos virtual integration.""" diff --git a/homeassistant/components/pitsos/manifest.json b/homeassistant/components/pitsos/manifest.json new file mode 100644 index 00000000000..55f5ac7b2fc --- /dev/null +++ b/homeassistant/components/pitsos/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "pitsos", + "name": "Pitsos", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 3c9f35b20a4..48459a81860 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -14,7 +14,6 @@ from plexauth import PlexAuth import requests.exceptions import voluptuous as vol -from homeassistant.components import http from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ( @@ -36,7 +35,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, discovery_flow +from homeassistant.helpers import config_validation as cv, discovery_flow, http from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json index 4f5ca3f2bc4..0c8eae86f73 100644 --- a/homeassistant/components/plex/strings.json +++ b/homeassistant/components/plex/strings.json @@ -11,7 +11,7 @@ } }, "manual_setup": { - "title": "Manual Plex Configuration", + "title": "Manual Plex configuration", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", @@ -29,8 +29,8 @@ } }, "error": { - "faulty_credentials": "Authorization failed, verify Token", - "host_or_token": "Must provide at least one of Host or Token", + "faulty_credentials": "Authorization failed, verify token", + "host_or_token": "Must provide at least one of host or token", "no_servers": "No servers linked to Plex account", "not_found": "Plex server not found", "ssl_error": "SSL certificate issue" @@ -47,12 +47,12 @@ "options": { "step": { "plex_mp_settings": { - "description": "Options for Plex Media Players", + "description": "Options for Plex media players", "data": { "use_episode_art": "Use episode art", "ignore_new_shared_users": "Ignore new managed/shared users", "monitored_users": "Monitored users", - "ignore_plex_web_clients": "Ignore Plex Web clients" + "ignore_plex_web_clients": "Ignore Plex web clients" } } } @@ -62,6 +62,11 @@ "scan_clients": { "name": "Scan clients" } + }, + "update": { + "server_update": { + "name": "[%key:component::update::title%]" + } } }, "services": { diff --git a/homeassistant/components/plex/update.py b/homeassistant/components/plex/update.py index 9b7645cd078..bc1c6abf2ed 100644 --- a/homeassistant/components/plex/update.py +++ b/homeassistant/components/plex/update.py @@ -4,16 +4,16 @@ import logging from typing import Any from plexapi.exceptions import PlexApiException -import plexapi.server import requests.exceptions from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_SERVER_IDENTIFIER +from .const import CONF_SERVER_IDENTIFIER, DOMAIN from .helpers import get_plex_server _LOGGER = logging.getLogger(__name__) @@ -27,9 +27,8 @@ async def async_setup_entry( """Set up Plex update entities from a config entry.""" server_id = config_entry.data[CONF_SERVER_IDENTIFIER] server = get_plex_server(hass, server_id) - plex_server = server.plex_server - can_update = await hass.async_add_executor_job(plex_server.canInstallUpdate) - async_add_entities([PlexUpdate(plex_server, can_update)], update_before_add=True) + can_update = await hass.async_add_executor_job(server.plex_server.canInstallUpdate) + async_add_entities([PlexUpdate(server, can_update)], update_before_add=True) class PlexUpdate(UpdateEntity): @@ -37,22 +36,21 @@ class PlexUpdate(UpdateEntity): _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES _release_notes: str | None = None + _attr_translation_key: str = "server_update" + _attr_has_entity_name = True - def __init__( - self, plex_server: plexapi.server.PlexServer, can_update: bool - ) -> None: + def __init__(self, plex_server, can_update: bool) -> None: """Initialize the Update entity.""" - self.plex_server = plex_server - self._attr_name = f"Plex Media Server ({plex_server.friendlyName})" - self._attr_unique_id = plex_server.machineIdentifier + self._server = plex_server + self._attr_unique_id = plex_server.machine_identifier if can_update: self._attr_supported_features |= UpdateEntityFeature.INSTALL def update(self) -> None: """Update sync attributes.""" - self._attr_installed_version = self.plex_server.version + self._attr_installed_version = self._server.version try: - if (release := self.plex_server.checkForUpdate()) is None: + if (release := self._server.plex_server.checkForUpdate()) is None: self._attr_latest_version = self.installed_version return except (requests.exceptions.RequestException, PlexApiException): @@ -73,6 +71,18 @@ class PlexUpdate(UpdateEntity): def install(self, version: str | None, backup: bool, **kwargs: Any) -> None: """Install an update.""" try: - self.plex_server.installUpdate() + self._server.plex_server.installUpdate() except (requests.exceptions.RequestException, PlexApiException) as exc: raise HomeAssistantError(str(exc)) from exc + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry.""" + return DeviceInfo( + identifiers={(DOMAIN, self._server.machine_identifier)}, + manufacturer="Plex", + model="Plex Media Server", + name=self._server.friendly_name, + sw_version=self._server.version, + configuration_url=f"{self._server.url_in_use}/web", + ) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 87878980f2d..3f812c1a63b 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "quality_scale": "platinum", - "requirements": ["plugwise==1.7.2"], + "requirements": ["plugwise==1.7.3"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index d16b38df992..d26e70d1c4f 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -19,11 +19,11 @@ "host": "[%key:common::config_flow::data::ip%]", "password": "Smile ID", "port": "[%key:common::config_flow::data::port%]", - "username": "Smile Username" + "username": "Smile username" }, "data_description": { "password": "The Smile ID printed on the label on the back of your Adam, Smile-T, or P1.", - "host": "The hostname or IP-address of your Smile. You can find it in your router or the Plugwise App.", + "host": "The hostname or IP-address of your Smile. You can find it in your router or the Plugwise app.", "port": "By default your Smile uses port 80, normally you should not have to change this.", "username": "Default is `smile`, or `stretch` for the legacy Stretch." } @@ -85,7 +85,7 @@ "preset_mode": { "state": { "asleep": "Night", - "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "away": "[%key:common::state::not_home%]", "home": "[%key:common::state::home%]", "no_frost": "Anti-frost", "vacation": "Vacation" @@ -113,7 +113,7 @@ "name": "DHW mode", "state": { "off": "[%key:common::state::off%]", - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "boost": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::boost%]", "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]" } @@ -122,7 +122,7 @@ "name": "Gateway mode", "state": { "away": "Pause", - "full": "Normal", + "full": "[%key:common::state::normal%]", "vacation": "Vacation" } }, @@ -139,7 +139,7 @@ "select_schedule": { "name": "Thermostat schedule", "state": { - "off": "Off" + "off": "[%key:common::state::off%]" } } }, @@ -184,7 +184,7 @@ "name": "Electricity consumed peak interval" }, "electricity_consumed_off_peak_interval": { - "name": "Electricity consumed off peak interval" + "name": "Electricity consumed off-peak interval" }, "electricity_produced_interval": { "name": "Electricity produced interval" @@ -193,19 +193,19 @@ "name": "Electricity produced peak interval" }, "electricity_produced_off_peak_interval": { - "name": "Electricity produced off peak interval" + "name": "Electricity produced off-peak interval" }, "electricity_consumed_point": { "name": "Electricity consumed point" }, "electricity_consumed_off_peak_point": { - "name": "Electricity consumed off peak point" + "name": "Electricity consumed off-peak point" }, "electricity_consumed_peak_point": { "name": "Electricity consumed peak point" }, "electricity_consumed_off_peak_cumulative": { - "name": "Electricity consumed off peak cumulative" + "name": "Electricity consumed off-peak cumulative" }, "electricity_consumed_peak_cumulative": { "name": "Electricity consumed peak cumulative" @@ -214,13 +214,13 @@ "name": "Electricity produced point" }, "electricity_produced_off_peak_point": { - "name": "Electricity produced off peak point" + "name": "Electricity produced off-peak point" }, "electricity_produced_peak_point": { "name": "Electricity produced peak point" }, "electricity_produced_off_peak_cumulative": { - "name": "Electricity produced off peak cumulative" + "name": "Electricity produced off-peak cumulative" }, "electricity_produced_peak_cumulative": { "name": "Electricity produced peak cumulative" diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index e446606f191..5c782bb3304 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -1,107 +1,28 @@ """Support for Minut Point.""" -import asyncio -from dataclasses import dataclass from http import HTTPStatus import logging from aiohttp import ClientError, ClientResponseError, web from pypoint import PointSession -import voluptuous as vol from homeassistant.components import webhook -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_WEBHOOK_ID, - Platform, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_WEBHOOK_ID, Platform +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import ( - aiohttp_client, - config_entry_oauth2_flow, - config_validation as cv, -) +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from . import api -from .const import ( - CONF_WEBHOOK_URL, - DOMAIN, - EVENT_RECEIVED, - POINT_DISCOVERY_NEW, - SCAN_INTERVAL, - SIGNAL_UPDATE_ENTITY, - SIGNAL_WEBHOOK, -) +from .const import CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, SIGNAL_WEBHOOK +from .coordinator import PointDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -type PointConfigEntry = ConfigEntry[PointData] - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Minut Point component.""" - if DOMAIN not in config: - return True - - conf = config[DOMAIN] - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.4.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Point", - }, - ) - - if not hass.config_entries.async_entries(DOMAIN): - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential( - conf[CONF_CLIENT_ID], - conf[CONF_CLIENT_SECRET], - ), - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) - ) - - return True +type PointConfigEntry = ConfigEntry[PointDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool: @@ -131,9 +52,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> boo point_session = PointSession(auth) - client = MinutPointClient(hass, entry, point_session) - hass.async_create_task(client.update()) - entry.runtime_data = PointData(client) + coordinator = PointDataUpdateCoordinator(hass, point_session) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator await async_setup_webhook(hass, entry, point_session) await hass.config_entries.async_forward_entry_setups( @@ -176,7 +99,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bo if unload_ok := await hass.config_entries.async_unload_platforms( entry, [*PLATFORMS, Platform.ALARM_CONTROL_PANEL] ): - session: PointSession = entry.runtime_data.client + session = entry.runtime_data.point if CONF_WEBHOOK_ID in entry.data: webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) await session.remove_webhook() @@ -197,87 +120,3 @@ async def handle_webhook( data["webhook_id"] = webhook_id async_dispatcher_send(hass, SIGNAL_WEBHOOK, data, data.get("hook_id")) hass.bus.async_fire(EVENT_RECEIVED, data) - - -class MinutPointClient: - """Get the latest data and update the states.""" - - def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, session: PointSession - ) -> None: - """Initialize the Minut data object.""" - self._known_devices: set[str] = set() - self._known_homes: set[str] = set() - self._hass = hass - self._config_entry = config_entry - self._is_available = True - self._client = session - - async_track_time_interval(self._hass, self.update, SCAN_INTERVAL) - - async def update(self, *args): - """Periodically poll the cloud for current state.""" - await self._sync() - - async def _sync(self): - """Update local list of devices.""" - if not await self._client.update(): - self._is_available = False - _LOGGER.warning("Device is unavailable") - async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) - return - - self._is_available = True - for home_id in self._client.homes: - if home_id not in self._known_homes: - async_dispatcher_send( - self._hass, - POINT_DISCOVERY_NEW.format(Platform.ALARM_CONTROL_PANEL), - home_id, - ) - self._known_homes.add(home_id) - for device in self._client.devices: - if device.device_id not in self._known_devices: - for platform in PLATFORMS: - async_dispatcher_send( - self._hass, - POINT_DISCOVERY_NEW.format(platform), - device.device_id, - ) - self._known_devices.add(device.device_id) - async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) - - def device(self, device_id): - """Return device representation.""" - return self._client.device(device_id) - - def is_available(self, device_id): - """Return device availability.""" - if not self._is_available: - return False - return device_id in self._client.device_ids - - async def remove_webhook(self): - """Remove the session webhook.""" - return await self._client.remove_webhook() - - @property - def homes(self): - """Return known homes.""" - return self._client.homes - - async def async_alarm_disarm(self, home_id): - """Send alarm disarm command.""" - return await self._client.alarm_disarm(home_id) - - async def async_alarm_arm(self, home_id): - """Send alarm arm command.""" - return await self._client.alarm_arm(home_id) - - -@dataclass -class PointData: - """Point Data.""" - - client: MinutPointClient - entry_lock: asyncio.Lock = asyncio.Lock() diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index 0f501d2ee09..fa56bf70546 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -2,23 +2,22 @@ from __future__ import annotations -from collections.abc import Callable import logging +from pypoint import PointSession + from homeassistant.components.alarm_control_panel import ( - DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, AlarmControlPanelEntity, AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import MinutPointClient -from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK +from . import PointConfigEntry +from .const import DOMAIN as POINT_DOMAIN, SIGNAL_WEBHOOK _LOGGER = logging.getLogger(__name__) @@ -32,21 +31,20 @@ EVENT_MAP = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PointConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's alarm_control_panel based on a config entry.""" + coordinator = config_entry.runtime_data - async def async_discover_home(home_id): + def async_discover_home(home_id: str) -> None: """Discover and add a discovered home.""" - client = config_entry.runtime_data.client - async_add_entities([MinutPointAlarmControl(client, home_id)], True) + async_add_entities([MinutPointAlarmControl(coordinator.point, home_id)]) - async_dispatcher_connect( - hass, - POINT_DISCOVERY_NEW.format(ALARM_CONTROL_PANEL_DOMAIN, POINT_DOMAIN), - async_discover_home, - ) + coordinator.new_home_callback = async_discover_home + + for home_id in coordinator.point.homes: + async_discover_home(home_id) class MinutPointAlarmControl(AlarmControlPanelEntity): @@ -55,12 +53,11 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY _attr_code_arm_required = False - def __init__(self, point_client: MinutPointClient, home_id: str) -> None: + def __init__(self, point: PointSession, home_id: str) -> None: """Initialize the entity.""" - self._client = point_client + self._client = point self._home_id = home_id - self._async_unsub_hook_dispatcher_connect: Callable[[], None] | None = None - self._home = point_client.homes[self._home_id] + self._home = point.homes[self._home_id] self._attr_name = self._home["name"] self._attr_unique_id = f"point.{home_id}" @@ -73,16 +70,10 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): async def async_added_to_hass(self) -> None: """Call when entity is added to HOme Assistant.""" await super().async_added_to_hass() - self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect( - self.hass, SIGNAL_WEBHOOK, self._webhook_event + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_WEBHOOK, self._webhook_event) ) - async def async_will_remove_from_hass(self) -> None: - """Disconnect dispatcher listener when removed.""" - await super().async_will_remove_from_hass() - if self._async_unsub_hook_dispatcher_connect: - self._async_unsub_hook_dispatcher_connect() - @callback def _webhook_event(self, data, webhook): """Process new event from the webhook.""" @@ -107,12 +98,12 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - status = await self._client.async_alarm_disarm(self._home_id) + status = await self._client.alarm_disarm(self._home_id) if status: self._home["alarm_status"] = "off" async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - status = await self._client.async_alarm_arm(self._home_id) + status = await self._client.alarm_arm(self._home_id) if status: self._home["alarm_status"] = "on" diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py index c9338cb63f2..17fe40b9654 100644 --- a/homeassistant/components/point/binary_sensor.py +++ b/homeassistant/components/point/binary_sensor.py @@ -3,26 +3,27 @@ from __future__ import annotations import logging +from typing import Any from pypoint import EVENTS from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, ) -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 AddConfigEntryEntitiesCallback -from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK +from . import PointConfigEntry +from .const import SIGNAL_WEBHOOK +from .coordinator import PointDataUpdateCoordinator from .entity import MinutPointEntity _LOGGER = logging.getLogger(__name__) -DEVICES = { +DEVICES: dict[str, Any] = { "alarm": {"icon": "mdi:alarm-bell"}, "battery": {"device_class": BinarySensorDeviceClass.BATTERY}, "button_press": {"icon": "mdi:gesture-tap-button"}, @@ -42,69 +43,60 @@ DEVICES = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PointConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's binary sensors based on a config entry.""" - async def async_discover_sensor(device_id): + coordinator = config_entry.runtime_data + + def async_discover_sensor(device_id: str) -> None: """Discover and add a discovered sensor.""" - client = config_entry.runtime_data.client async_add_entities( - ( - MinutPointBinarySensor(client, device_id, device_name) - for device_name in DEVICES - if device_name in EVENTS - ), - True, + MinutPointBinarySensor(coordinator, device_id, device_name) + for device_name in DEVICES + if device_name in EVENTS ) - async_dispatcher_connect( - hass, - POINT_DISCOVERY_NEW.format(BINARY_SENSOR_DOMAIN, POINT_DOMAIN), - async_discover_sensor, + coordinator.new_device_callbacks.append(async_discover_sensor) + + async_add_entities( + MinutPointBinarySensor(coordinator, device_id, device_name) + for device_name in DEVICES + if device_name in EVENTS + for device_id in coordinator.point.device_ids ) class MinutPointBinarySensor(MinutPointEntity, BinarySensorEntity): """The platform class required by Home Assistant.""" - def __init__(self, point_client, device_id, device_name): + def __init__( + self, coordinator: PointDataUpdateCoordinator, device_id: str, key: str + ) -> None: """Initialize the binary sensor.""" - super().__init__( - point_client, - device_id, - DEVICES[device_name].get("device_class", device_name), - ) - self._device_name = device_name - self._async_unsub_hook_dispatcher_connect = None - self._events = EVENTS[device_name] - self._attr_unique_id = f"point.{device_id}-{device_name}" - self._attr_icon = DEVICES[self._device_name].get("icon") + self._attr_device_class = DEVICES[key].get("device_class", key) + super().__init__(coordinator, device_id) + self._device_name = key + self._events = EVENTS[key] + self._attr_unique_id = f"point.{device_id}-{key}" + self._attr_icon = DEVICES[key].get("icon") async def async_added_to_hass(self) -> None: """Call when entity is added to HOme Assistant.""" await super().async_added_to_hass() - self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect( - self.hass, SIGNAL_WEBHOOK, self._webhook_event + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_WEBHOOK, self._webhook_event) ) - async def async_will_remove_from_hass(self) -> None: - """Disconnect dispatcher listener when removed.""" - await super().async_will_remove_from_hass() - if self._async_unsub_hook_dispatcher_connect: - self._async_unsub_hook_dispatcher_connect() - - async def _update_callback(self): + def _handle_coordinator_update(self) -> None: """Update the value of the sensor.""" - if not self.is_updated: - return if self.device_class == BinarySensorDeviceClass.CONNECTIVITY: # connectivity is the other way around. self._attr_is_on = self._events[0] not in self.device.ongoing_events else: self._attr_is_on = self._events[0] in self.device.ongoing_events - self.async_write_ha_state() + super()._handle_coordinator_update() @callback def _webhook_event(self, data, webhook): diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index b26ade8b725..426177a1849 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -24,10 +24,6 @@ class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """Return logger.""" return logging.getLogger(__name__) - async def async_step_import(self, data: dict[str, Any]) -> ConfigFlowResult: - """Handle import from YAML.""" - return await self.async_step_user() - async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/point/coordinator.py b/homeassistant/components/point/coordinator.py new file mode 100644 index 00000000000..c0cb4e27646 --- /dev/null +++ b/homeassistant/components/point/coordinator.py @@ -0,0 +1,70 @@ +"""Define a data update coordinator for Point.""" + +from collections.abc import Callable +from datetime import datetime +import logging +from typing import Any + +from pypoint import PointSession +from tempora.utc import fromtimestamp + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.dt import parse_datetime + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class PointDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Class to manage fetching Point data from the API.""" + + def __init__(self, hass: HomeAssistant, point: PointSession) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.point = point + self.device_updates: dict[str, datetime] = {} + self._known_devices: set[str] = set() + self._known_homes: set[str] = set() + self.new_home_callback: Callable[[str], None] | None = None + self.new_device_callbacks: list[Callable[[str], None]] = [] + self.data: dict[str, dict[str, Any]] = {} + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + if not await self.point.update(): + raise UpdateFailed("Failed to fetch data from Point") + + if new_homes := set(self.point.homes) - self._known_homes: + _LOGGER.debug("Found new homes: %s", new_homes) + for home_id in new_homes: + if self.new_home_callback: + self.new_home_callback(home_id) + self._known_homes.update(new_homes) + + device_ids = {device.device_id for device in self.point.devices} + if new_devices := device_ids - self._known_devices: + _LOGGER.debug("Found new devices: %s", new_devices) + for device_id in new_devices: + for callback in self.new_device_callbacks: + callback(device_id) + self._known_devices.update(new_devices) + + for device in self.point.devices: + last_updated = parse_datetime(device.last_update) + if ( + not last_updated + or device.device_id not in self.device_updates + or self.device_updates[device.device_id] < last_updated + ): + self.device_updates[device.device_id] = last_updated or fromtimestamp(0) + self.data[device.device_id] = { + k: await device.sensor(k) + for k in ("temperature", "humidity", "sound_pressure") + } + return self.data diff --git a/homeassistant/components/point/entity.py b/homeassistant/components/point/entity.py index 5c52e81e6f7..39af7867e97 100644 --- a/homeassistant/components/point/entity.py +++ b/homeassistant/components/point/entity.py @@ -2,31 +2,27 @@ import logging +from pypoint import Device, PointSession + from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity -from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.dt import as_local -from .const import DOMAIN, SIGNAL_UPDATE_ENTITY +from .const import DOMAIN +from .coordinator import PointDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -class MinutPointEntity(Entity): +class MinutPointEntity(CoordinatorEntity[PointDataUpdateCoordinator]): """Base Entity used by the sensors.""" - _attr_should_poll = False - - def __init__(self, point_client, device_id, device_class) -> None: + def __init__(self, coordinator: PointDataUpdateCoordinator, device_id: str) -> None: """Initialize the entity.""" - self._async_unsub_dispatcher_connect = None - self._client = point_client - self._id = device_id + super().__init__(coordinator) + self.device_id = device_id self._name = self.device.name - self._attr_device_class = device_class - self._updated = utc_from_timestamp(0) - self._attr_unique_id = f"point.{device_id}-{device_class}" device = self.device.device self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, @@ -37,59 +33,32 @@ class MinutPointEntity(Entity): sw_version=device["firmware"]["installed"], via_device=(DOMAIN, device["home"]), ) - if device_class: - self._attr_name = f"{self._name} {device_class.capitalize()}" - - def __str__(self) -> str: - """Return string representation of device.""" - return f"MinutPoint {self.name}" - - async def async_added_to_hass(self): - """Call when entity is added to hass.""" - _LOGGER.debug("Created device %s", self) - self._async_unsub_dispatcher_connect = async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback - ) - await self._update_callback() - - async def async_will_remove_from_hass(self) -> None: - """Disconnect dispatcher listener when removed.""" - if self._async_unsub_dispatcher_connect: - self._async_unsub_dispatcher_connect() + if self.device_class: + self._attr_name = f"{self._name} {self.device_class.capitalize()}" async def _update_callback(self): """Update the value of the sensor.""" + @property + def client(self) -> PointSession: + """Return the client object.""" + return self.coordinator.point + @property def available(self) -> bool: """Return true if device is not offline.""" - return self._client.is_available(self.device_id) + return super().available and self.device_id in self.client.device_ids @property - def device(self): + def device(self) -> Device: """Return the representation of the device.""" - return self._client.device(self.device_id) - - @property - def device_id(self): - """Return the id of the device.""" - return self._id + return self.client.device(self.device_id) @property def extra_state_attributes(self): """Return status of device.""" attrs = self.device.device_status - attrs["last_heard_from"] = as_local(self.last_update).strftime( - "%Y-%m-%d %H:%M:%S" - ) + attrs["last_heard_from"] = as_local( + self.coordinator.device_updates[self.device_id] + ).strftime("%Y-%m-%d %H:%M:%S") return attrs - - @property - def is_updated(self): - """Return true if sensor have been updated.""" - return self.last_update > self._updated - - @property - def last_update(self): - """Return the last_update time for the device.""" - return parse_datetime(self.device.last_update) diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index c959d09d606..246536d86ab 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -5,19 +5,17 @@ from __future__ import annotations import logging from homeassistant.components.sensor import ( - DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfSoundPressure, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util.dt import parse_datetime +from homeassistant.helpers.typing import StateType -from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW +from . import PointConfigEntry +from .coordinator import PointDataUpdateCoordinator from .entity import MinutPointEntity _LOGGER = logging.getLogger(__name__) @@ -37,7 +35,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( - key="sound", + key="sound_pressure", suggested_display_precision=1, device_class=SensorDeviceClass.SOUND_PRESSURE, native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, @@ -47,26 +45,26 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PointConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's sensors based on a config entry.""" - async def async_discover_sensor(device_id): + coordinator = config_entry.runtime_data + + def async_discover_sensor(device_id: str) -> None: """Discover and add a discovered sensor.""" - client = config_entry.runtime_data.client async_add_entities( - [ - MinutPointSensor(client, device_id, description) - for description in SENSOR_TYPES - ], - True, + MinutPointSensor(coordinator, device_id, description) + for description in SENSOR_TYPES ) - async_dispatcher_connect( - hass, - POINT_DISCOVERY_NEW.format(SENSOR_DOMAIN, POINT_DOMAIN), - async_discover_sensor, + coordinator.new_device_callbacks.append(async_discover_sensor) + + async_add_entities( + MinutPointSensor(coordinator, device_id, description) + for device_id in coordinator.data + for description in SENSOR_TYPES ) @@ -74,16 +72,17 @@ class MinutPointSensor(MinutPointEntity, SensorEntity): """The platform class required by Home Assistant.""" def __init__( - self, point_client, device_id, description: SensorEntityDescription + self, + coordinator: PointDataUpdateCoordinator, + device_id: str, + description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(point_client, device_id, description.device_class) self.entity_description = description + super().__init__(coordinator, device_id) + self._attr_unique_id = f"point.{device_id}-{description.key}" - async def _update_callback(self): - """Update the value of the sensor.""" - _LOGGER.debug("Update sensor value for %s", self) - if self.is_updated: - self._attr_native_value = await self.device.sensor(self.device_class) - self._updated = parse_datetime(self.device.last_update) - self.async_write_ha_state() + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.coordinator.data[self.device_id].get(self.entity_description.key) diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index b4988133727..f242d2c67e6 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -297,7 +297,6 @@ class PowerWallBackupReserveSensor(PowerWallEntity, SensorEntity): _attr_translation_key = "backup_reserve" _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = PERCENTAGE - _attr_device_class = SensorDeviceClass.BATTERY @property def unique_id(self) -> str: diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index df24f536527..f1e1839b735 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.26.5"] + "requirements": ["bluetooth-data-tools==1.28.1"] } diff --git a/homeassistant/components/profilo/__init__.py b/homeassistant/components/profilo/__init__.py new file mode 100644 index 00000000000..5f727b1bc8b --- /dev/null +++ b/homeassistant/components/profilo/__init__.py @@ -0,0 +1 @@ +"""Profilo virtual integration.""" diff --git a/homeassistant/components/profilo/manifest.json b/homeassistant/components/profilo/manifest.json new file mode 100644 index 00000000000..c5671d5be3f --- /dev/null +++ b/homeassistant/components/profilo/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "profilo", + "name": "Profilo", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/prosegur/strings.json b/homeassistant/components/prosegur/strings.json index 9b9ac45fc85..e5176e96090 100644 --- a/homeassistant/components/prosegur/strings.json +++ b/homeassistant/components/prosegur/strings.json @@ -5,7 +5,7 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "country": "Country" + "country": "[%key:common::config_flow::data::country%]" } }, "choose_contract": { diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 0db6ea28652..11fa530f47b 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -6,7 +6,6 @@ from datetime import timedelta from typing import Any from proxmoxer import AuthenticationError, ProxmoxAPI -from proxmoxer.core import ResourceException import requests.exceptions from requests.exceptions import ConnectTimeout, SSLError import voluptuous as vol @@ -25,6 +24,7 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .common import ProxmoxClient, call_api_container_vm, parse_api_container_vm from .const import ( _LOGGER, CONF_CONTAINERS, @@ -219,80 +219,3 @@ def create_coordinator_container_vm( update_method=async_update_data, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - - -def parse_api_container_vm(status: dict[str, Any]) -> dict[str, Any]: - """Get the container or vm api data and return it formatted in a dictionary. - - It is implemented in this way to allow for more data to be added for sensors - in the future. - """ - - return {"status": status["status"], "name": status["name"]} - - -def call_api_container_vm( - proxmox: ProxmoxAPI, - node_name: str, - vm_id: int, - machine_type: int, -) -> dict[str, Any] | None: - """Make proper api calls.""" - status = None - - try: - if machine_type == TYPE_VM: - status = proxmox.nodes(node_name).qemu(vm_id).status.current.get() - elif machine_type == TYPE_CONTAINER: - status = proxmox.nodes(node_name).lxc(vm_id).status.current.get() - except (ResourceException, requests.exceptions.ConnectionError): - return None - - return status - - -class ProxmoxClient: - """A wrapper for the proxmoxer ProxmoxAPI client.""" - - _proxmox: ProxmoxAPI - - def __init__( - self, - host: str, - port: int, - user: str, - realm: str, - password: str, - verify_ssl: bool, - ) -> None: - """Initialize the ProxmoxClient.""" - - self._host = host - self._port = port - self._user = user - self._realm = realm - self._password = password - self._verify_ssl = verify_ssl - - def build_client(self) -> None: - """Construct the ProxmoxAPI client. - - Allows inserting the realm within the `user` value. - """ - - if "@" in self._user: - user_id = self._user - else: - user_id = f"{self._user}@{self._realm}" - - self._proxmox = ProxmoxAPI( - self._host, - port=self._port, - user=user_id, - password=self._password, - verify_ssl=self._verify_ssl, - ) - - def get_api_client(self) -> ProxmoxAPI: - """Return the ProxmoxAPI client.""" - return self._proxmox diff --git a/homeassistant/components/proxmoxve/common.py b/homeassistant/components/proxmoxve/common.py new file mode 100644 index 00000000000..4173377377c --- /dev/null +++ b/homeassistant/components/proxmoxve/common.py @@ -0,0 +1,88 @@ +"""Commons for Proxmox VE integration.""" + +from __future__ import annotations + +from typing import Any + +from proxmoxer import ProxmoxAPI +from proxmoxer.core import ResourceException +import requests.exceptions + +from .const import TYPE_CONTAINER, TYPE_VM + + +class ProxmoxClient: + """A wrapper for the proxmoxer ProxmoxAPI client.""" + + _proxmox: ProxmoxAPI + + def __init__( + self, + host: str, + port: int, + user: str, + realm: str, + password: str, + verify_ssl: bool, + ) -> None: + """Initialize the ProxmoxClient.""" + + self._host = host + self._port = port + self._user = user + self._realm = realm + self._password = password + self._verify_ssl = verify_ssl + + def build_client(self) -> None: + """Construct the ProxmoxAPI client. + + Allows inserting the realm within the `user` value. + """ + + if "@" in self._user: + user_id = self._user + else: + user_id = f"{self._user}@{self._realm}" + + self._proxmox = ProxmoxAPI( + self._host, + port=self._port, + user=user_id, + password=self._password, + verify_ssl=self._verify_ssl, + ) + + def get_api_client(self) -> ProxmoxAPI: + """Return the ProxmoxAPI client.""" + return self._proxmox + + +def parse_api_container_vm(status: dict[str, Any]) -> dict[str, Any]: + """Get the container or vm api data and return it formatted in a dictionary. + + It is implemented in this way to allow for more data to be added for sensors + in the future. + """ + + return {"status": status["status"], "name": status["name"]} + + +def call_api_container_vm( + proxmox: ProxmoxAPI, + node_name: str, + vm_id: int, + machine_type: int, +) -> dict[str, Any] | None: + """Make proper api calls.""" + status = None + + try: + if machine_type == TYPE_VM: + status = proxmox.nodes(node_name).qemu(vm_id).status.current.get() + elif machine_type == TYPE_CONTAINER: + status = proxmox.nodes(node_name).lxc(vm_id).status.current.get() + except (ResourceException, requests.exceptions.ConnectionError): + return None + + return status diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 6925b9e2133..02074a18b61 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", "quality_scale": "legacy", - "requirements": ["Pillow==11.1.0"] + "requirements": ["Pillow==11.2.1"] } diff --git a/homeassistant/components/prusalink/strings.json b/homeassistant/components/prusalink/strings.json index 7c6f0bbf2dd..6c698cf3dc2 100644 --- a/homeassistant/components/prusalink/strings.json +++ b/homeassistant/components/prusalink/strings.json @@ -36,8 +36,8 @@ "printing": "Printing", "paused": "[%key:common::state::paused%]", "finished": "Finished", - "stopped": "Stopped", - "error": "Error", + "stopped": "[%key:common::state::stopped%]", + "error": "[%key:common::state::error%]", "attention": "Attention", "ready": "Ready" } @@ -85,7 +85,7 @@ "name": "Z-Height" }, "nozzle_diameter": { - "name": "Nozzle Diameter" + "name": "Nozzle diameter" } }, "button": { diff --git a/homeassistant/components/pterodactyl/__init__.py b/homeassistant/components/pterodactyl/__init__.py index 33b3cc7576f..c0e23b271d1 100644 --- a/homeassistant/components/pterodactyl/__init__.py +++ b/homeassistant/components/pterodactyl/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from .coordinator import PterodactylConfigEntry, PterodactylCoordinator -_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: PterodactylConfigEntry) -> bool: diff --git a/homeassistant/components/pterodactyl/api.py b/homeassistant/components/pterodactyl/api.py index 38cb9809652..2aac359a5c6 100644 --- a/homeassistant/components/pterodactyl/api.py +++ b/homeassistant/components/pterodactyl/api.py @@ -1,23 +1,20 @@ """API module of the Pterodactyl integration.""" from dataclasses import dataclass +from enum import StrEnum import logging from pydactyl import PterodactylClient -from pydactyl.exceptions import ( - BadRequestError, - ClientConfigError, - PterodactylApiError, - PydactylError, -) +from pydactyl.exceptions import BadRequestError, PterodactylApiError +from requests.exceptions import ConnectionError, HTTPError from homeassistant.core import HomeAssistant _LOGGER = logging.getLogger(__name__) -class PterodactylConfigurationError(Exception): - """Raised when the configuration is invalid.""" +class PterodactylAuthorizationError(Exception): + """Raised when access to server is unauthorized.""" class PterodactylConnectionError(Exception): @@ -32,14 +29,26 @@ class PterodactylData: uuid: str identifier: str state: str - memory_utilization: int cpu_utilization: float - disk_utilization: int - network_rx_utilization: int - network_tx_utilization: int + cpu_limit: int + disk_usage: int + disk_limit: int + memory_usage: int + memory_limit: int + network_inbound: int + network_outbound: int uptime: int +class PterodactylCommand(StrEnum): + """Command enum for the Pterodactyl server.""" + + START_SERVER = "start" + STOP_SERVER = "stop" + RESTART_SERVER = "restart" + FORCE_STOP_SERVER = "kill" + + class PterodactylAPI: """Wrapper for Pterodactyl's API.""" @@ -54,24 +63,31 @@ class PterodactylAPI: self.pterodactyl = None self.identifiers = [] + def get_game_servers(self) -> list[str]: + """Get all game servers.""" + paginated_response = self.pterodactyl.client.servers.list_servers() # type: ignore[union-attr] + + return paginated_response.collect() + async def async_init(self): """Initialize the Pterodactyl API.""" self.pterodactyl = PterodactylClient(self.host, self.api_key) try: - paginated_response = await self.hass.async_add_executor_job( - self.pterodactyl.client.servers.list_servers - ) - except ClientConfigError as error: - raise PterodactylConfigurationError(error) from error + game_servers = await self.hass.async_add_executor_job(self.get_game_servers) except ( - PydactylError, BadRequestError, PterodactylApiError, + ConnectionError, + StopIteration, ) as error: + raise PterodactylConnectionError(error) from error + except HTTPError as error: + if error.response.status_code == 401: + raise PterodactylAuthorizationError(error) from error + raise PterodactylConnectionError(error) from error else: - game_servers = paginated_response.collect() for game_server in game_servers: self.identifiers.append(game_server["attributes"]["identifier"]) @@ -95,11 +111,12 @@ class PterodactylAPI: server, utilization = await self.hass.async_add_executor_job( self.get_server_data, identifier ) - except ( - PydactylError, - BadRequestError, - PterodactylApiError, - ) as error: + except (BadRequestError, PterodactylApiError, ConnectionError) as error: + raise PterodactylConnectionError(error) from error + except HTTPError as error: + if error.response.status_code == 401: + raise PterodactylAuthorizationError(error) from error + raise PterodactylConnectionError(error) from error else: data[identifier] = PterodactylData( @@ -108,13 +125,34 @@ class PterodactylAPI: identifier=identifier, state=utilization["current_state"], cpu_utilization=utilization["resources"]["cpu_absolute"], - memory_utilization=utilization["resources"]["memory_bytes"], - disk_utilization=utilization["resources"]["disk_bytes"], - network_rx_utilization=utilization["resources"]["network_rx_bytes"], - network_tx_utilization=utilization["resources"]["network_tx_bytes"], + cpu_limit=server["limits"]["cpu"], + memory_usage=utilization["resources"]["memory_bytes"], + memory_limit=server["limits"]["memory"], + disk_usage=utilization["resources"]["disk_bytes"], + disk_limit=server["limits"]["disk"], + network_inbound=utilization["resources"]["network_rx_bytes"], + network_outbound=utilization["resources"]["network_tx_bytes"], uptime=utilization["resources"]["uptime"], ) _LOGGER.debug("%s", data[identifier]) return data + + async def async_send_command( + self, identifier: str, command: PterodactylCommand + ) -> None: + """Send a command to the Pterodactyl server.""" + try: + await self.hass.async_add_executor_job( + self.pterodactyl.client.servers.send_power_action, # type: ignore[union-attr] + identifier, + command, + ) + except (BadRequestError, PterodactylApiError, ConnectionError) as error: + raise PterodactylConnectionError(error) from error + except HTTPError as error: + if error.response.status_code == 401: + raise PterodactylAuthorizationError(error) from error + + raise PterodactylConnectionError(error) from error diff --git a/homeassistant/components/pterodactyl/button.py b/homeassistant/components/pterodactyl/button.py new file mode 100644 index 00000000000..44d3a6d0a82 --- /dev/null +++ b/homeassistant/components/pterodactyl/button.py @@ -0,0 +1,106 @@ +"""Button platform for the Pterodactyl integration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .api import ( + PterodactylAuthorizationError, + PterodactylCommand, + PterodactylConnectionError, +) +from .coordinator import PterodactylConfigEntry, PterodactylCoordinator +from .entity import PterodactylEntity + +KEY_START_SERVER = "start_server" +KEY_STOP_SERVER = "stop_server" +KEY_RESTART_SERVER = "restart_server" +KEY_FORCE_STOP_SERVER = "force_stop_server" + +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class PterodactylButtonEntityDescription(ButtonEntityDescription): + """Class describing Pterodactyl button entities.""" + + command: PterodactylCommand + + +BUTTON_DESCRIPTIONS = [ + PterodactylButtonEntityDescription( + key=KEY_START_SERVER, + translation_key=KEY_START_SERVER, + command=PterodactylCommand.START_SERVER, + ), + PterodactylButtonEntityDescription( + key=KEY_STOP_SERVER, + translation_key=KEY_STOP_SERVER, + command=PterodactylCommand.STOP_SERVER, + ), + PterodactylButtonEntityDescription( + key=KEY_RESTART_SERVER, + translation_key=KEY_RESTART_SERVER, + command=PterodactylCommand.RESTART_SERVER, + ), + PterodactylButtonEntityDescription( + key=KEY_FORCE_STOP_SERVER, + translation_key=KEY_FORCE_STOP_SERVER, + command=PterodactylCommand.FORCE_STOP_SERVER, + entity_registry_enabled_default=False, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PterodactylConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Pterodactyl button platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + PterodactylButtonEntity(coordinator, identifier, description, config_entry) + for identifier in coordinator.api.identifiers + for description in BUTTON_DESCRIPTIONS + ) + + +class PterodactylButtonEntity(PterodactylEntity, ButtonEntity): + """Representation of a Pterodactyl button entity.""" + + entity_description: PterodactylButtonEntityDescription + + def __init__( + self, + coordinator: PterodactylCoordinator, + identifier: str, + description: PterodactylButtonEntityDescription, + config_entry: PterodactylConfigEntry, + ) -> None: + """Initialize the button entity.""" + super().__init__(coordinator, identifier, config_entry) + self.entity_description = description + self._attr_unique_id = f"{self.game_server_data.uuid}_{description.key}" + + async def async_press(self) -> None: + """Handle the button press.""" + try: + await self.coordinator.api.async_send_command( + self.identifier, self.entity_description.command + ) + except PterodactylConnectionError as err: + raise HomeAssistantError( + f"Failed to send action '{self.entity_description.key}': Connection error" + ) from err + except PterodactylAuthorizationError as err: + raise HomeAssistantError( + f"Failed to send action '{self.entity_description.key}': Unauthorized" + ) from err diff --git a/homeassistant/components/pterodactyl/config_flow.py b/homeassistant/components/pterodactyl/config_flow.py index a36069d2bb9..db03c89f95e 100644 --- a/homeassistant/components/pterodactyl/config_flow.py +++ b/homeassistant/components/pterodactyl/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -13,7 +14,7 @@ from homeassistant.const import CONF_API_KEY, CONF_URL from .api import ( PterodactylAPI, - PterodactylConfigurationError, + PterodactylAuthorizationError, PterodactylConnectionError, ) from .const import DOMAIN @@ -29,34 +30,81 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } +) + class PterodactylConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Pterodactyl.""" VERSION = 1 + async def async_validate_connection(self, url: str, api_key: str) -> dict[str, str]: + """Validate the connection to the Pterodactyl server.""" + errors: dict[str, str] = {} + api = PterodactylAPI(self.hass, url, api_key) + + try: + await api.async_init() + except PterodactylAuthorizationError: + errors["base"] = "invalid_auth" + except PterodactylConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception occurred during config flow") + errors["base"] = "unknown" + + return errors + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" errors: dict[str, str] = {} + if user_input is not None: url = URL(user_input[CONF_URL]).human_repr() api_key = user_input[CONF_API_KEY] self._async_abort_entries_match({CONF_URL: url}) - api = PterodactylAPI(self.hass, url, api_key) + errors = await self.async_validate_connection(url, api_key) - try: - await api.async_init() - except (PterodactylConfigurationError, PterodactylConnectionError): - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception occurred during config flow") - errors["base"] = "unknown" - else: + if not errors: return self.async_create_entry(title=url, data=user_input) 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 re-authentication on an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that re-authentication is required.""" + errors: dict[str, str] = {} + + if user_input is not None: + reauth_entry = self._get_reauth_entry() + url = reauth_entry.data[CONF_URL] + api_key = user_input[CONF_API_KEY] + + errors = await self.async_validate_connection(url, api_key) + + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/pterodactyl/coordinator.py b/homeassistant/components/pterodactyl/coordinator.py index 36456ade630..6d644e96e4c 100644 --- a/homeassistant/components/pterodactyl/coordinator.py +++ b/homeassistant/components/pterodactyl/coordinator.py @@ -8,11 +8,12 @@ import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import ( PterodactylAPI, - PterodactylConfigurationError, + PterodactylAuthorizationError, PterodactylConnectionError, PterodactylData, ) @@ -55,8 +56,10 @@ class PterodactylCoordinator(DataUpdateCoordinator[dict[str, PterodactylData]]): try: await self.api.async_init() - except PterodactylConfigurationError as error: + except PterodactylConnectionError as error: raise UpdateFailed(error) from error + except PterodactylAuthorizationError as error: + raise ConfigEntryAuthFailed(error) from error async def _async_update_data(self) -> dict[str, PterodactylData]: """Get updated data from the Pterodactyl server.""" @@ -64,3 +67,5 @@ class PterodactylCoordinator(DataUpdateCoordinator[dict[str, PterodactylData]]): return await self.api.async_get_data() except PterodactylConnectionError as error: raise UpdateFailed(error) from error + except PterodactylAuthorizationError as error: + raise ConfigEntryAuthFailed(error) from error diff --git a/homeassistant/components/pterodactyl/icons.json b/homeassistant/components/pterodactyl/icons.json new file mode 100644 index 00000000000..265a8dcadda --- /dev/null +++ b/homeassistant/components/pterodactyl/icons.json @@ -0,0 +1,47 @@ +{ + "entity": { + "button": { + "start_server": { + "default": "mdi:play" + }, + "stop_server": { + "default": "mdi:stop" + }, + "restart_server": { + "default": "mdi:refresh" + }, + "force_stop_server": { + "default": "mdi:flash-alert" + } + }, + "sensor": { + "cpu_utilization": { + "default": "mdi:cpu-64-bit" + }, + "cpu_limit": { + "default": "mdi:cpu-64-bit" + }, + "memory_usage": { + "default": "mdi:memory" + }, + "memory_limit": { + "default": "mdi:memory" + }, + "disk_usage": { + "default": "mdi:harddisk" + }, + "disk_limit": { + "default": "mdi:harddisk" + }, + "network_inbound": { + "default": "mdi:download" + }, + "network_outbound": { + "default": "mdi:upload" + }, + "uptime": { + "default": "mdi:timer" + } + } + } +} diff --git a/homeassistant/components/pterodactyl/quality_scale.yaml b/homeassistant/components/pterodactyl/quality_scale.yaml index dae3b9fa11a..80ebb3fc7e3 100644 --- a/homeassistant/components/pterodactyl/quality_scale.yaml +++ b/homeassistant/components/pterodactyl/quality_scale.yaml @@ -51,7 +51,7 @@ rules: status: done comment: Handled by coordinator. parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: todo # Gold diff --git a/homeassistant/components/pterodactyl/sensor.py b/homeassistant/components/pterodactyl/sensor.py new file mode 100644 index 00000000000..646b429cd08 --- /dev/null +++ b/homeassistant/components/pterodactyl/sensor.py @@ -0,0 +1,183 @@ +"""Sensor platform of the Pterodactyl integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util + +from .coordinator import PterodactylConfigEntry, PterodactylCoordinator, PterodactylData +from .entity import PterodactylEntity + +KEY_CPU_UTILIZATION = "cpu_utilization" +KEY_CPU_LIMIT = "cpu_limit" +KEY_MEMORY_USAGE = "memory_usage" +KEY_MEMORY_LIMIT = "memory_limit" +KEY_DISK_USAGE = "disk_usage" +KEY_DISK_LIMIT = "disk_limit" +KEY_NETWORK_INBOUND = "network_inbound" +KEY_NETWORK_OUTBOUND = "network_outbound" +KEY_UPTIME = "uptime" + +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class PterodactylSensorEntityDescription(SensorEntityDescription): + """Class describing Pterodactyl sensor entities.""" + + value_fn: Callable[[PterodactylData], StateType | datetime] + + +SENSOR_DESCRIPTIONS = [ + PterodactylSensorEntityDescription( + key=KEY_CPU_UTILIZATION, + translation_key=KEY_CPU_UTILIZATION, + value_fn=lambda data: data.cpu_utilization, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + ), + PterodactylSensorEntityDescription( + key=KEY_CPU_LIMIT, + translation_key=KEY_CPU_LIMIT, + value_fn=lambda data: data.cpu_limit, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_MEMORY_USAGE, + translation_key=KEY_MEMORY_USAGE, + value_fn=lambda data: data.memory_usage, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + ), + PterodactylSensorEntityDescription( + key=KEY_MEMORY_LIMIT, + translation_key=KEY_MEMORY_LIMIT, + value_fn=lambda data: data.memory_limit, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_DISK_USAGE, + translation_key=KEY_DISK_USAGE, + value_fn=lambda data: data.disk_usage, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + ), + PterodactylSensorEntityDescription( + key=KEY_DISK_LIMIT, + translation_key=KEY_DISK_LIMIT, + value_fn=lambda data: data.disk_limit, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_NETWORK_INBOUND, + translation_key=KEY_NETWORK_INBOUND, + value_fn=lambda data: data.network_inbound, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_NETWORK_OUTBOUND, + translation_key=KEY_NETWORK_OUTBOUND, + value_fn=lambda data: data.network_outbound, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_UPTIME, + translation_key=KEY_UPTIME, + value_fn=( + lambda data: dt_util.utcnow() - timedelta(milliseconds=data.uptime) + if data.uptime > 0 + else None + ), + device_class=SensorDeviceClass.TIMESTAMP, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PterodactylConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Pterodactyl sensor platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + PterodactylSensorEntity(coordinator, identifier, description, config_entry) + for identifier in coordinator.api.identifiers + for description in SENSOR_DESCRIPTIONS + ) + + +class PterodactylSensorEntity(PterodactylEntity, SensorEntity): + """Representation of a Pterodactyl sensor base entity.""" + + entity_description: PterodactylSensorEntityDescription + + def __init__( + self, + coordinator: PterodactylCoordinator, + identifier: str, + description: PterodactylSensorEntityDescription, + config_entry: PterodactylConfigEntry, + ) -> None: + """Initialize sensor base entity.""" + super().__init__(coordinator, identifier, config_entry) + self.entity_description = description + self._attr_unique_id = f"{self.game_server_data.uuid}_{description.key}" + + @property + def native_value(self) -> StateType | datetime: + """Return native value of sensor.""" + return self.entity_description.value_fn(self.game_server_data) diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json index a875c72ccd8..3d01700f189 100644 --- a/homeassistant/components/pterodactyl/strings.json +++ b/homeassistant/components/pterodactyl/strings.json @@ -10,14 +10,26 @@ "url": "The URL of your Pterodactyl server, including the protocol (http:// or https://) and optionally the port number.", "api_key": "The account API key for accessing your Pterodactyl server." } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Please update your account API key.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::pterodactyl::config::step::user::data_description::api_key%]" + } } }, "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_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { @@ -25,6 +37,49 @@ "status": { "name": "Status" } + }, + "button": { + "start_server": { + "name": "Start server" + }, + "stop_server": { + "name": "Stop server" + }, + "restart_server": { + "name": "Restart server" + }, + "force_stop_server": { + "name": "Force stop server" + } + }, + "sensor": { + "cpu_utilization": { + "name": "CPU utilization" + }, + "cpu_limit": { + "name": "CPU limit" + }, + "memory_usage": { + "name": "Memory usage" + }, + "memory_limit": { + "name": "Memory limit" + }, + "disk_usage": { + "name": "Disk usage" + }, + "disk_limit": { + "name": "Disk limit" + }, + "network_inbound": { + "name": "Network inbound" + }, + "network_outbound": { + "name": "Network outbound" + }, + "uptime": { + "name": "Uptime" + } } } } diff --git a/homeassistant/components/pushover/manifest.json b/homeassistant/components/pushover/manifest.json index d086321c088..e13a254c423 100644 --- a/homeassistant/components/pushover/manifest.json +++ b/homeassistant/components/pushover/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/pushover", "iot_class": "cloud_push", "loggers": ["pushover_complete"], - "requirements": ["pushover_complete==1.1.1"] + "requirements": ["pushover_complete==1.2.0"] } diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index bd9897aa6ba..2f813e35557 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["qbittorrent"], - "requirements": ["qbittorrent-api==2024.2.59"] + "requirements": ["qbittorrent-api==2024.9.67"] } diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 23ec485fcd4..d565d2f7b5f 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -218,7 +218,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( key=SENSOR_TYPE_PAUSED_TORRENTS, translation_key="paused_torrents", value_fn=lambda coordinator: count_torrents_in_states( - coordinator, ["pausedDL", "pausedUP"] + coordinator, ["stoppedDL", "stoppedUP"] ), ), ) diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index ee613eb96c2..ef2f45bbc28 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -53,9 +53,9 @@ "connection_status": { "name": "Connection status", "state": { - "connected": "Connected", + "connected": "[%key:common::state::connected%]", "firewalled": "Firewalled", - "disconnected": "Disconnected" + "disconnected": "[%key:common::state::disconnected%]" } }, "active_torrents": { diff --git a/homeassistant/components/qnap/coordinator.py b/homeassistant/components/qnap/coordinator.py index 297f6569d2b..a6d654ddbbd 100644 --- a/homeassistant/components/qnap/coordinator.py +++ b/homeassistant/components/qnap/coordinator.py @@ -2,11 +2,13 @@ from __future__ import annotations +from contextlib import contextmanager, nullcontext from datetime import timedelta import logging from typing import Any from qnapstats import QNAPStats +import urllib3 from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -28,6 +30,17 @@ UPDATE_INTERVAL = timedelta(minutes=1) _LOGGER = logging.getLogger(__name__) +@contextmanager +def suppress_insecure_request_warning(): + """Context manager to suppress InsecureRequestWarning. + + Was added in here to solve the following issue, not being solved upstream. + https://github.com/colinodell/python-qnapstats/issues/96 + """ + with urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning): + yield + + class QnapCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): """Custom coordinator for the qnap integration.""" @@ -42,24 +55,31 @@ class QnapCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): ) protocol = "https" if config_entry.data[CONF_SSL] else "http" + self._verify_ssl = config_entry.data.get(CONF_VERIFY_SSL) + self._api = QNAPStats( f"{protocol}://{config_entry.data.get(CONF_HOST)}", config_entry.data.get(CONF_PORT), config_entry.data.get(CONF_USERNAME), config_entry.data.get(CONF_PASSWORD), - verify_ssl=config_entry.data.get(CONF_VERIFY_SSL), + verify_ssl=self._verify_ssl, timeout=config_entry.data.get(CONF_TIMEOUT), ) def _sync_update(self) -> dict[str, dict[str, Any]]: """Get the latest data from the Qnap API.""" - return { - "system_stats": self._api.get_system_stats(), - "system_health": self._api.get_system_health(), - "smart_drive_health": self._api.get_smart_disk_health(), - "volumes": self._api.get_volumes(), - "bandwidth": self._api.get_bandwidth(), - } + with ( + suppress_insecure_request_warning() + if not self._verify_ssl + else nullcontext() + ): + return { + "system_stats": self._api.get_system_stats(), + "system_health": self._api.get_system_health(), + "smart_drive_health": self._api.get_smart_disk_health(), + "volumes": self._api.get_volumes(), + "bandwidth": self._api.get_bandwidth(), + } async def _async_update_data(self) -> dict[str, dict[str, Any]]: """Get the latest data from the Qnap API.""" diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index cd3ee8eca42..e29e95abc62 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -6,5 +6,5 @@ "iot_class": "calculated", "loggers": ["pyzbar"], "quality_scale": "legacy", - "requirements": ["Pillow==11.1.0", "pyzbar==0.1.7"] + "requirements": ["Pillow==11.2.1", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 58427b0e5ba..6f4cbf4f02c 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -59,7 +59,7 @@ async def async_setup_entry( coordinator, SensorEntityDescription( key="zigbee:Price", - translation_key="meter_price", + translation_key="energy_price", native_unit_of_measurement=f"{coordinator.data['zigbee:PriceCurrency']}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, ), diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json index 7b5054bfb0f..08e237d5af0 100644 --- a/homeassistant/components/rainforest_eagle/strings.json +++ b/homeassistant/components/rainforest_eagle/strings.json @@ -5,7 +5,7 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "cloud_id": "Cloud ID", - "install_code": "Installation Code" + "install_code": "Installation code" }, "data_description": { "host": "The hostname or IP address of your Rainforest gateway." @@ -24,16 +24,16 @@ "entity": { "sensor": { "power_demand": { - "name": "Meter power demand" + "name": "Power demand" }, "total_energy_delivered": { - "name": "Total meter energy delivered" + "name": "Total energy delivered" }, "total_energy_received": { - "name": "Total meter energy received" + "name": "Total energy received" }, - "meter_price": { - "name": "Meter price" + "energy_price": { + "name": "Energy price" } } } diff --git a/homeassistant/components/rainforest_raven/sensor.py b/homeassistant/components/rainforest_raven/sensor.py index 3d358322b70..658689c7e6c 100644 --- a/homeassistant/components/rainforest_raven/sensor.py +++ b/homeassistant/components/rainforest_raven/sensor.py @@ -101,7 +101,7 @@ async def async_setup_entry( coordinator, RAVEnSensorEntityDescription( message_key="PriceCluster", - translation_key="meter_price", + translation_key="energy_price", key="price", native_unit_of_measurement=f"{meter_data['PriceCluster']['currency'].value}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/rainforest_raven/strings.json b/homeassistant/components/rainforest_raven/strings.json index fb667d64d3f..bc2653aea87 100644 --- a/homeassistant/components/rainforest_raven/strings.json +++ b/homeassistant/components/rainforest_raven/strings.json @@ -12,7 +12,7 @@ "step": { "meters": { "data": { - "mac": "Meter MAC Addresses" + "mac": "Meter MAC addresses" } }, "user": { @@ -24,27 +24,27 @@ }, "entity": { "sensor": { - "meter_price": { - "name": "Meter price", + "energy_price": { + "name": "Energy price", "state_attributes": { "rate_label": { "name": "Rate" }, "tier": { "name": "Tier" } } }, "power_demand": { - "name": "Meter power demand" + "name": "Power demand" }, "signal_strength": { - "name": "Meter signal strength", + "name": "Signal strength", "state_attributes": { "channel": { "name": "Channel" } } }, "total_energy_delivered": { - "name": "Total meter energy delivered" + "name": "Total energy delivered" }, "total_energy_received": { - "name": "Total meter energy received" + "name": "Total energy received" } } } diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json index bacd6dd5a17..af0efb823b9 100644 --- a/homeassistant/components/random/strings.json +++ b/homeassistant/components/random/strings.json @@ -98,6 +98,7 @@ "distance": "[%key:component::sensor::entity_component::distance::name%]", "duration": "[%key:component::sensor::entity_component::duration::name%]", "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]", "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", "frequency": "[%key:component::sensor::entity_component::frequency::name%]", "gas": "[%key:component::sensor::entity_component::gas::name%]", @@ -134,6 +135,7 @@ "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", "water": "[%key:component::sensor::entity_component::water::name%]", "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]", "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" } } diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 7cb71e70f65..c0bffbe9615 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -170,12 +170,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: exclude_event_types=exclude_event_types, ) get_instance.cache_clear() + entity_registry.async_setup(hass) instance.async_initialize() instance.async_register() instance.start() async_register_services(hass, instance) websocket_api.async_setup(hass) - entity_registry.async_setup(hass) await _async_setup_integration_platform(hass, instance) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 7b8043b9201..34fa6a62d44 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -1307,11 +1307,17 @@ class Recorder(threading.Thread): async def async_block_till_done(self) -> None: """Async version of block_till_done.""" + if future := self.async_get_commit_future(): + await future + + @callback + def async_get_commit_future(self) -> asyncio.Future[None] | None: + """Return a future that will wait for the next commit or None if nothing pending.""" if self._queue.empty() and not self._event_session_has_pending_writes: - return - event = asyncio.Event() - self.queue_task(SynchronizeTask(event)) - await event.wait() + return None + future: asyncio.Future[None] = self.hass.loop.create_future() + self.queue_task(SynchronizeTask(future)) + return future def block_till_done(self) -> None: """Block till all events processed. diff --git a/homeassistant/components/recorder/entity_registry.py b/homeassistant/components/recorder/entity_registry.py index 07f8f2f88de..30a3a1b8239 100644 --- a/homeassistant/components/recorder/entity_registry.py +++ b/homeassistant/components/recorder/entity_registry.py @@ -4,8 +4,9 @@ import logging from typing import TYPE_CHECKING from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.start import async_at_start +from homeassistant.helpers.event import async_has_entity_registry_updated_listeners from .core import Recorder from .util import filter_unique_constraint_integrity_error, get_instance, session_scope @@ -40,16 +41,17 @@ def async_setup(hass: HomeAssistant) -> None: """Handle entity_id changed filter.""" return event_data["action"] == "update" and "old_entity_id" in event_data - @callback - def _setup_entity_registry_event_handler(hass: HomeAssistant) -> None: - """Subscribe to event registry events.""" - hass.bus.async_listen( - er.EVENT_ENTITY_REGISTRY_UPDATED, - _async_entity_id_changed, - event_filter=entity_registry_changed_filter, + if async_has_entity_registry_updated_listeners(hass): + raise HomeAssistantError( + "The recorder entity registry listener must be installed" + " before async_track_entity_registry_updated_event is called" ) - async_at_start(hass, _setup_entity_registry_event_handler) + hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, + _async_entity_id_changed, + event_filter=entity_registry_changed_filter, + ) def update_states_metadata( diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index f5336e2a85b..01b5d089bf3 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,8 +7,8 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.39", - "fnv-hash-fast==1.4.0", + "SQLAlchemy==2.0.40", + "fnv-hash-fast==1.5.0", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index 919ee078a99..28459cfef07 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -104,7 +104,7 @@ class LazyState(State): return self._last_updated_ts @cached_property - def last_changed_timestamp(self) -> float: # type: ignore[override] + def last_changed_timestamp(self) -> float: """Last changed timestamp.""" ts = self._last_changed_ts or self._last_updated_ts if TYPE_CHECKING: @@ -112,7 +112,7 @@ class LazyState(State): return ts @cached_property - def last_reported_timestamp(self) -> float: # type: ignore[override] + def last_reported_timestamp(self) -> float: """Last reported timestamp.""" ts = self._last_reported_ts or self._last_updated_ts if TYPE_CHECKING: diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 4eb9547ee9d..f5ad7f2a3d9 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -317,13 +317,18 @@ class SynchronizeTask(RecorderTask): """Ensure all pending data has been committed.""" # commit_before is the default - event: asyncio.Event + future: asyncio.Future def run(self, instance: Recorder) -> None: """Handle the task.""" # Does not use a tracked task to avoid # blocking shutdown if the recorder is broken - instance.hass.loop.call_soon_threadsafe(self.event.set) + instance.hass.loop.call_soon_threadsafe(self._set_result_if_not_done) + + def _set_result_if_not_done(self) -> None: + """Set the result if not done.""" + if not self.future.done(): + self.future.set_result(None) @dataclass(slots=True) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 0acaf0aa68f..b7b1a8e17a3 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -650,7 +650,7 @@ def _wrap_retryable_database_job_func_or_meth[**_P]( # Failed with retryable error return False - _LOGGER.warning("Error executing %s: %s", description, err) + _LOGGER.error("Error executing %s: %s", description, err) # Failed with permanent error return True diff --git a/homeassistant/components/rehlko/__init__.py b/homeassistant/components/rehlko/__init__.py new file mode 100644 index 00000000000..bda2704a206 --- /dev/null +++ b/homeassistant/components/rehlko/__init__.py @@ -0,0 +1,100 @@ +"""The Rehlko integration.""" + +from __future__ import annotations + +import logging + +from aiokem import AioKem, AuthenticationError + +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 ( + CONF_REFRESH_TOKEN, + CONNECTION_EXCEPTIONS, + DEVICE_DATA_DEVICES, + DEVICE_DATA_DISPLAY_NAME, + DEVICE_DATA_ID, + DOMAIN, +) +from .coordinator import RehlkoConfigEntry, RehlkoRuntimeData, RehlkoUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: RehlkoConfigEntry) -> bool: + """Set up Rehlko from a config entry.""" + websession = async_get_clientsession(hass) + rehlko = AioKem(session=websession) + # If requests take more than 20 seconds; timeout and let the setup retry. + rehlko.set_timeout(20) + + async def async_refresh_token_update(refresh_token: str) -> None: + """Handle refresh token update.""" + _LOGGER.debug("Saving refresh token") + # Update the config entry with the new refresh token + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_REFRESH_TOKEN: refresh_token}, + ) + + rehlko.set_refresh_token_callback(async_refresh_token_update) + + try: + await rehlko.authenticate( + entry.data[CONF_EMAIL], + entry.data[CONF_PASSWORD], + entry.data.get(CONF_REFRESH_TOKEN), + ) + homes = await rehlko.get_homes() + except AuthenticationError as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={CONF_EMAIL: entry.data[CONF_EMAIL]}, + ) from ex + except CONNECTION_EXCEPTIONS as ex: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from ex + coordinators: dict[int, RehlkoUpdateCoordinator] = {} + + entry.runtime_data = RehlkoRuntimeData( + coordinators=coordinators, + rehlko=rehlko, + homes=homes, + ) + + for home_data in homes: + for device_data in home_data[DEVICE_DATA_DEVICES]: + device_id = device_data[DEVICE_DATA_ID] + coordinator = RehlkoUpdateCoordinator( + hass=hass, + logger=_LOGGER, + config_entry=entry, + home_data=home_data, + device_id=device_id, + device_data=device_data, + rehlko=rehlko, + name=f"{DOMAIN} {device_data[DEVICE_DATA_DISPLAY_NAME]}", + ) + # Intentionally done in series to avoid overloading + # the Rehlko API with requests + await coordinator.async_config_entry_first_refresh() + coordinators[device_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Retrys enabled after successful connection to prevent blocking startup + rehlko.set_retry_policy(retry_count=3, retry_delays=[5, 10, 20]) + # Rehlko service can be slow to respond, increase timeout for polls. + rehlko.set_timeout(100) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: RehlkoConfigEntry) -> bool: + """Unload a config entry.""" + await entry.runtime_data.rehlko.close() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/rehlko/config_flow.py b/homeassistant/components/rehlko/config_flow.py new file mode 100644 index 00000000000..16f97bb385a --- /dev/null +++ b/homeassistant/components/rehlko/config_flow.py @@ -0,0 +1,103 @@ +"""Config flow for Rehlko integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from aiokem import AioKem, AuthenticationError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONNECTION_EXCEPTIONS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class RehlkoConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Rehlko.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + errors, token_subject = await self._async_validate_or_error(user_input) + if not errors: + await self.async_set_unique_id(token_subject) + self._abort_if_unique_id_configured() + email: str = user_input[CONF_EMAIL] + normalized_email = email.lower() + return self.async_create_entry(title=normalized_email, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def _async_validate_or_error( + self, config: dict[str, Any] + ) -> tuple[dict[str, str], str | None]: + """Validate the user input.""" + errors: dict[str, str] = {} + token_subject = None + rehlko = AioKem(session=async_get_clientsession(self.hass)) + try: + await rehlko.authenticate(config[CONF_EMAIL], config[CONF_PASSWORD]) + except CONNECTION_EXCEPTIONS: + errors["base"] = "cannot_connect" + except AuthenticationError: + errors[CONF_PASSWORD] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + token_subject = rehlko.get_token_subject() + return errors, token_subject + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth input.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + existing_data = reauth_entry.data + description_placeholders: dict[str, str] = { + CONF_EMAIL: existing_data[CONF_EMAIL] + } + if user_input is not None: + errors, _ = await self._async_validate_or_error( + {**existing_data, **user_input} + ) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + + return self.async_show_form( + description_placeholders=description_placeholders, + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + errors=errors, + ) diff --git a/homeassistant/components/rehlko/const.py b/homeassistant/components/rehlko/const.py new file mode 100644 index 00000000000..f63c0872d46 --- /dev/null +++ b/homeassistant/components/rehlko/const.py @@ -0,0 +1,25 @@ +"""Constants for the Rehlko integration.""" + +from aiokem import CommunicationError + +DOMAIN = "rehlko" + +CONF_REFRESH_TOKEN = "refresh_token" + +DEVICE_DATA_DEVICES = "devices" +DEVICE_DATA_PRODUCT = "product" +DEVICE_DATA_FIRMWARE_VERSION = "firmwareVersion" +DEVICE_DATA_MODEL_NAME = "modelDisplayName" +DEVICE_DATA_ID = "id" +DEVICE_DATA_DISPLAY_NAME = "displayName" +DEVICE_DATA_MAC_ADDRESS = "macAddress" +DEVICE_DATA_IS_CONNECTED = "isConnected" + +KOHLER = "Kohler" + +GENERATOR_DATA_DEVICE = "device" + +CONNECTION_EXCEPTIONS = ( + TimeoutError, + CommunicationError, +) diff --git a/homeassistant/components/rehlko/coordinator.py b/homeassistant/components/rehlko/coordinator.py new file mode 100644 index 00000000000..f5a268dff74 --- /dev/null +++ b/homeassistant/components/rehlko/coordinator.py @@ -0,0 +1,78 @@ +"""The Rehlko coordinator.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +from aiokem import AioKem, CommunicationError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type RehlkoConfigEntry = ConfigEntry[RehlkoRuntimeData] + +SCAN_INTERVAL_MINUTES = timedelta(minutes=10) + + +@dataclass +class RehlkoRuntimeData: + """Dataclass to hold runtime data for the Rehlko integration.""" + + coordinators: dict[int, RehlkoUpdateCoordinator] + rehlko: AioKem + homes: list[dict[str, Any]] + + +class RehlkoUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching Rehlko data API.""" + + config_entry: RehlkoConfigEntry + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + config_entry: RehlkoConfigEntry, + rehlko: AioKem, + home_data: dict[str, Any], + device_data: dict[str, Any], + device_id: int, + name: str, + ) -> None: + """Initialize.""" + self.rehlko = rehlko + self.device_data = device_data + self.device_id = device_id + self.home_data = home_data + super().__init__( + hass=hass, + logger=logger, + config_entry=config_entry, + name=name, + update_interval=SCAN_INTERVAL_MINUTES, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via library.""" + try: + result = await self.rehlko.get_generator_data(self.device_id) + except CommunicationError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from error + return result + + @property + def entry_unique_id(self) -> str: + """Get the unique ID for the entry.""" + assert self.config_entry.unique_id + return self.config_entry.unique_id diff --git a/homeassistant/components/rehlko/entity.py b/homeassistant/components/rehlko/entity.py new file mode 100644 index 00000000000..94d384e1949 --- /dev/null +++ b/homeassistant/components/rehlko/entity.py @@ -0,0 +1,81 @@ +"""Base class for Rehlko entities.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + DEVICE_DATA_DISPLAY_NAME, + DEVICE_DATA_FIRMWARE_VERSION, + DEVICE_DATA_IS_CONNECTED, + DEVICE_DATA_MAC_ADDRESS, + DEVICE_DATA_MODEL_NAME, + DEVICE_DATA_PRODUCT, + DOMAIN, + GENERATOR_DATA_DEVICE, + KOHLER, +) +from .coordinator import RehlkoUpdateCoordinator + + +def _get_device_connections(mac_address: str) -> set[tuple[str, str]]: + """Get device connections.""" + try: + mac_address_hex = mac_address.replace(":", "") + except ValueError: # MacAddress may be invalid if the gateway is offline + return set() + return {(dr.CONNECTION_NETWORK_MAC, mac_address_hex)} + + +class RehlkoEntity(CoordinatorEntity[RehlkoUpdateCoordinator]): + """Representation of a Rehlko entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: RehlkoUpdateCoordinator, + device_id: int, + device_data: dict, + description: EntityDescription, + use_device_key: bool = False, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._device_id = device_id + self._attr_unique_id = ( + f"{coordinator.entry_unique_id}_{device_id}_{description.key}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.entry_unique_id}_{device_id}")}, + name=device_data[DEVICE_DATA_DISPLAY_NAME], + hw_version=device_data[DEVICE_DATA_PRODUCT], + sw_version=device_data[DEVICE_DATA_FIRMWARE_VERSION], + model=device_data[DEVICE_DATA_MODEL_NAME], + manufacturer=KOHLER, + connections=_get_device_connections(device_data[DEVICE_DATA_MAC_ADDRESS]), + ) + self._use_device_key = use_device_key + + @property + def _device_data(self) -> dict[str, Any]: + """Return the device data.""" + return self.coordinator.data[GENERATOR_DATA_DEVICE] + + @property + def _rehlko_value(self) -> str: + """Return the sensor value.""" + if self._use_device_key: + return self._device_data[self.entity_description.key] + return self.coordinator.data[self.entity_description.key] + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._device_data[DEVICE_DATA_IS_CONNECTED] diff --git a/homeassistant/components/rehlko/icons.json b/homeassistant/components/rehlko/icons.json new file mode 100644 index 00000000000..309fc2ffd27 --- /dev/null +++ b/homeassistant/components/rehlko/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "engine_speed": { + "default": "mdi:speedometer" + }, + "engine_state": { + "default": "mdi:engine" + }, + "device_ip_address": { + "default": "mdi:ip-network" + }, + "server_ip_address": { + "default": "mdi:server-network" + }, + "generator_status": { + "default": "mdi:home-lightning-bolt" + }, + "power_source": { + "default": "mdi:transmission-tower" + } + } + } +} diff --git a/homeassistant/components/rehlko/manifest.json b/homeassistant/components/rehlko/manifest.json new file mode 100644 index 00000000000..6b2f6190883 --- /dev/null +++ b/homeassistant/components/rehlko/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "rehlko", + "name": "Rehlko", + "codeowners": ["@bdraco", "@peterager"], + "config_flow": true, + "dhcp": [ + { + "hostname": "kohlergen*", + "macaddress": "00146F*" + } + ], + "documentation": "https://www.home-assistant.io/integrations/rehlko", + "iot_class": "cloud_polling", + "loggers": ["aiokem"], + "quality_scale": "silver", + "requirements": ["aiokem==0.5.10"] +} diff --git a/homeassistant/components/rehlko/quality_scale.yaml b/homeassistant/components/rehlko/quality_scale.yaml new file mode 100644 index 00000000000..646fac448cc --- /dev/null +++ b/homeassistant/components/rehlko/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions are defined. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + 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: done + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No configuration parameters. + 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: Network information not useful as it is a cloud integration. + discovery: done + 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: | + Device type integration. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/rehlko/sensor.py b/homeassistant/components/rehlko/sensor.py new file mode 100644 index 00000000000..9186f0e0c9f --- /dev/null +++ b/homeassistant/components/rehlko/sensor.py @@ -0,0 +1,216 @@ +"""Support for Rehlko sensors.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + REVOLUTIONS_PER_MINUTE, + EntityCategory, + UnitOfElectricPotential, + UnitOfFrequency, + UnitOfPower, + UnitOfPressure, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DEVICE_DATA_DEVICES, DEVICE_DATA_ID +from .coordinator import RehlkoConfigEntry +from .entity import RehlkoEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class RehlkoSensorEntityDescription(SensorEntityDescription): + """Class describing Rehlko sensor entities.""" + + use_device_key: bool = False + + +SENSORS: tuple[RehlkoSensorEntityDescription, ...] = ( + RehlkoSensorEntityDescription( + key="engineSpeedRpm", + translation_key="engine_speed", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + ), + RehlkoSensorEntityDescription( + key="engineOilPressurePsi", + translation_key="engine_oil_pressure", + native_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="engineCoolantTempF", + translation_key="engine_coolant_temperature", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="batteryVoltageV", + translation_key="battery_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="lubeOilTempF", + translation_key="lube_oil_temperature", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RehlkoSensorEntityDescription( + key="controllerTempF", + translation_key="controller_temperature", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="engineCompartmentTempF", + translation_key="engine_compartment_temperature", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RehlkoSensorEntityDescription( + key="engineFrequencyHz", + translation_key="engine_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="totalOperationHours", + translation_key="total_operation", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + RehlkoSensorEntityDescription( + key="totalRuntimeHours", + translation_key="total_runtime", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + use_device_key=True, + ), + RehlkoSensorEntityDescription( + key="runtimeSinceLastMaintenanceHours", + translation_key="runtime_since_last_maintenance", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + RehlkoSensorEntityDescription( + key="deviceIpAddress", + translation_key="device_ip_address", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + use_device_key=True, + ), + RehlkoSensorEntityDescription( + key="serverIpAddress", + translation_key="server_ip_address", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + RehlkoSensorEntityDescription( + key="utilityVoltageV", + translation_key="utility_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="generatorVoltageAvgV", + translation_key="generator_voltage_avg", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="generatorLoadW", + translation_key="generator_load", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="generatorLoadPercent", + translation_key="generator_load_percent", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RehlkoSensorEntityDescription( + key="status", + translation_key="generator_status", + use_device_key=True, + ), + RehlkoSensorEntityDescription( + key="engineState", + translation_key="engine_state", + ), + RehlkoSensorEntityDescription( + key="powerSource", + translation_key="power_source", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RehlkoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensors.""" + + homes = config_entry.runtime_data.homes + coordinators = config_entry.runtime_data.coordinators + async_add_entities( + RehlkoSensorEntity( + coordinators[device_data[DEVICE_DATA_ID]], + device_data[DEVICE_DATA_ID], + device_data, + sensor_description, + sensor_description.use_device_key, + ) + for home_data in homes + for device_data in home_data[DEVICE_DATA_DEVICES] + for sensor_description in SENSORS + ) + + +class RehlkoSensorEntity(RehlkoEntity, SensorEntity): + """Representation of a Rehlko sensor.""" + + @property + def native_value(self) -> StateType: + """Return the sensor state.""" + return self._rehlko_value diff --git a/homeassistant/components/rehlko/strings.json b/homeassistant/components/rehlko/strings.json new file mode 100644 index 00000000000..6b842173558 --- /dev/null +++ b/homeassistant/components/rehlko/strings.json @@ -0,0 +1,108 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email used to log in to the Rehlko application.", + "password": "The password used to log in to the Rehlko application." + } + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::rehlko::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": { + "sensor": { + "engine_speed": { + "name": "Engine speed" + }, + "engine_oil_pressure": { + "name": "Engine oil pressure" + }, + "engine_coolant_temperature": { + "name": "Engine coolant temperature" + }, + "battery_voltage": { + "name": "Battery voltage" + }, + "lube_oil_temperature": { + "name": "Lube oil temperature" + }, + "controller_temperature": { + "name": "Controller temperature" + }, + "engine_compartment_temperature": { + "name": "Engine compartment temperature" + }, + "engine_frequency": { + "name": "Engine frequency" + }, + "total_operation": { + "name": "Total operation" + }, + "total_runtime": { + "name": "Total runtime" + }, + "runtime_since_last_maintenance": { + "name": "Runtime since last maintenance" + }, + "device_ip_address": { + "name": "Device IP address" + }, + "server_ip_address": { + "name": "Server IP address" + }, + "utility_voltage": { + "name": "Utility voltage" + }, + "generator_voltage_average": { + "name": "Average generator voltage" + }, + "generator_load": { + "name": "Generator load" + }, + "generator_load_percent": { + "name": "Generator load percentage" + }, + "engine_state": { + "name": "Engine state" + }, + "power_source": { + "name": "Power source" + }, + "generator_status": { + "name": "Generator status" + } + } + }, + "exceptions": { + "update_failed": { + "message": "Updating data failed after retries." + }, + "invalid_auth": { + "message": "Authentication failed for email {email}." + }, + "cannot_connect": { + "message": "Can not connect to Rehlko servers." + } + } +} diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py index bd83a5f18cc..2f60918f010 100644 --- a/homeassistant/components/remote_calendar/calendar.py +++ b/homeassistant/components/remote_calendar/calendar.py @@ -29,7 +29,7 @@ async def async_setup_entry( """Set up the remote calendar platform.""" coordinator = entry.runtime_data entity = RemoteCalendarEntity(coordinator, entry) - async_add_entities([entity]) + async_add_entities([entity], True) class RemoteCalendarEntity( @@ -48,25 +48,46 @@ class RemoteCalendarEntity( super().__init__(coordinator) self._attr_name = entry.data[CONF_CALENDAR_NAME] self._attr_unique_id = entry.entry_id + self._event: CalendarEvent | None = None @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" - now = dt_util.now() - events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now) - if event := next(events, None): - return _get_calendar_event(event) - return None + return self._event async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all events in a specific time frame.""" - events = self.coordinator.data.timeline_tz(start_date.tzinfo).overlapping( - start_date, - end_date, - ) - return [_get_calendar_event(event) for event in events] + + def events_in_range() -> list[CalendarEvent]: + """Return all events in the given time range.""" + events = self.coordinator.data.timeline_tz(start_date.tzinfo).overlapping( + start_date, + end_date, + ) + return [_get_calendar_event(event) for event in events] + + return await self.hass.async_add_executor_job(events_in_range) + + async def async_update(self) -> None: + """Refresh the timeline. + + This is called when the coordinator updates. Creating the timeline may + require walking through the entire calendar and handling recurring + events, so it is done as a separate task without blocking the event loop. + """ + await super().async_update() + + def next_timeline_event() -> CalendarEvent | None: + """Return the next active event.""" + now = dt_util.now() + events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now) + if event := next(events, None): + return _get_calendar_event(event) + return None + + self._event = await self.hass.async_add_executor_job(next_timeline_event) def _get_calendar_event(event: Event) -> CalendarEvent: diff --git a/homeassistant/components/remote_calendar/config_flow.py b/homeassistant/components/remote_calendar/config_flow.py index 802a7eb7cea..558a3d668ae 100644 --- a/homeassistant/components/remote_calendar/config_flow.py +++ b/homeassistant/components/remote_calendar/config_flow.py @@ -5,8 +5,6 @@ import logging from typing import Any from httpx import HTTPError, InvalidURL -from ical.calendar_stream import IcsCalendarStream -from ical.exceptions import CalendarParseError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -14,6 +12,7 @@ from homeassistant.const import CONF_URL from homeassistant.helpers.httpx_client import get_async_client from .const import CONF_CALENDAR_NAME, DOMAIN +from .ics import InvalidIcsException, parse_calendar _LOGGER = logging.getLogger(__name__) @@ -64,15 +63,9 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.debug("An error occurred: %s", err) else: try: - await self.hass.async_add_executor_job( - IcsCalendarStream.calendar_from_ics, res.text - ) - except CalendarParseError as err: + await parse_calendar(self.hass, res.text) + except InvalidIcsException: errors["base"] = "invalid_ics_file" - _LOGGER.error("Error reading the calendar information: %s", err.message) - _LOGGER.debug( - "Additional calendar error detail: %s", str(err.detailed_error) - ) else: return self.async_create_entry( title=user_input[CONF_CALENDAR_NAME], data=user_input diff --git a/homeassistant/components/remote_calendar/coordinator.py b/homeassistant/components/remote_calendar/coordinator.py index 6caec297c1a..1eead7682d3 100644 --- a/homeassistant/components/remote_calendar/coordinator.py +++ b/homeassistant/components/remote_calendar/coordinator.py @@ -5,8 +5,6 @@ import logging from httpx import HTTPError, InvalidURL from ical.calendar import Calendar -from ical.calendar_stream import IcsCalendarStream -from ical.exceptions import CalendarParseError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL @@ -15,6 +13,7 @@ from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN +from .ics import InvalidIcsException, parse_calendar type RemoteCalendarConfigEntry = ConfigEntry[RemoteCalendarDataUpdateCoordinator] @@ -56,14 +55,9 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): translation_placeholders={"err": str(err)}, ) from err try: - # calendar_from_ics will dynamically load packages - # the first time it is called, so we need to do it - # in a separate thread to avoid blocking the event loop self.ics = res.text - return await self.hass.async_add_executor_job( - IcsCalendarStream.calendar_from_ics, self.ics - ) - except CalendarParseError as err: + return await parse_calendar(self.hass, res.text) + except InvalidIcsException as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="unable_to_parse", diff --git a/homeassistant/components/remote_calendar/ics.py b/homeassistant/components/remote_calendar/ics.py new file mode 100644 index 00000000000..d0920d7ae32 --- /dev/null +++ b/homeassistant/components/remote_calendar/ics.py @@ -0,0 +1,44 @@ +"""Module for parsing ICS content. + +This module exists to fix known issues where calendar providers return calendars +that do not follow rfcc5545. This module will attempt to fix the calendar and return +a valid calendar object. +""" + +import logging + +from ical.calendar import Calendar +from ical.calendar_stream import IcsCalendarStream +from ical.compat import enable_compat_mode +from ical.exceptions import CalendarParseError + +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + + +class InvalidIcsException(Exception): + """Exception to indicate that the ICS content is invalid.""" + + +def _compat_calendar_from_ics(ics: str) -> Calendar: + """Parse the ICS content and return a Calendar object. + + This function is called in a separate thread to avoid blocking the event + loop while loading packages or parsing the ICS content for large calendars. + + It uses the `enable_compat_mode` context manager to fix known issues with + calendar providers that return invalid calendars. + """ + with enable_compat_mode(ics) as compat_ics: + return IcsCalendarStream.calendar_from_ics(compat_ics) + + +async def parse_calendar(hass: HomeAssistant, ics: str) -> Calendar: + """Parse the ICS content and return a Calendar object.""" + try: + return await hass.async_add_executor_job(_compat_calendar_from_ics, ics) + except CalendarParseError as err: + _LOGGER.error("Error parsing calendar information: %s", err.message) + _LOGGER.debug("Additional calendar error detail: %s", str(err.detailed_error)) + raise InvalidIcsException(err.message) from err diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index da078395484..b31fa3389dc 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==9.1.0"] + "requirements": ["ical==9.2.0"] } diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index 0aebd3bd835..5e4f08e9d5c 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from renault_api.kamereon.enums import ChargeState, PlugState @@ -22,6 +23,16 @@ from .entity import RenaultDataEntity, RenaultDataEntityDescription # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 +_PLUG_FROM_CHARGE_STATUS: set[ChargeState] = { + ChargeState.CHARGE_IN_PROGRESS, + ChargeState.WAITING_FOR_CURRENT_CHARGE, + ChargeState.CHARGE_ENDED, + ChargeState.V2G_CHARGING_NORMAL, + ChargeState.V2G_CHARGING_WAITING, + ChargeState.V2G_DISCHARGING, + ChargeState.WAITING_FOR_A_PLANNED_CHARGE, +} + @dataclass(frozen=True, kw_only=True) class RenaultBinarySensorEntityDescription( @@ -30,8 +41,9 @@ class RenaultBinarySensorEntityDescription( ): """Class describing Renault binary sensor entities.""" - on_key: str - on_value: StateType | list[StateType] + on_key: str | None = None + on_value: StateType | None = None + value_lambda: Callable[[RenaultBinarySensor], bool | None] | None = None async def async_setup_entry( @@ -59,25 +71,40 @@ class RenaultBinarySensor( @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" + + if self.entity_description.value_lambda is not None: + return self.entity_description.value_lambda(self) + if self.entity_description.on_key is None: + raise NotImplementedError("Either value_lambda or on_key must be set") if (data := self._get_data_attr(self.entity_description.on_key)) is None: return None - if isinstance(self.entity_description.on_value, list): - return data in self.entity_description.on_value return data == self.entity_description.on_value +def _plugged_in_value_lambda(self: RenaultBinarySensor) -> bool | None: + """Return true if the vehicle is plugged in.""" + + data = self.coordinator.data + plug_status = data.get_plug_status() if data else None + + if plug_status is not None: + return plug_status == PlugState.PLUGGED + + charging_status = data.get_charging_status() if data else None + if charging_status is not None and charging_status in _PLUG_FROM_CHARGE_STATUS: + return True + + return None + + BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = tuple( [ RenaultBinarySensorEntityDescription( key="plugged_in", coordinator="battery", device_class=BinarySensorDeviceClass.PLUG, - on_key="plugStatus", - on_value=[ - PlugState.PLUGGED.value, - PlugState.PLUGGED_WAITING_FOR_CHARGE.value, - ], + value_lambda=_plugged_in_value_lambda, ), RenaultBinarySensorEntityDescription( key="charging", diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py index 90d2c11613c..d46f0ff4a80 100644 --- a/homeassistant/components/renault/config_flow.py +++ b/homeassistant/components/renault/config_flow.py @@ -11,7 +11,11 @@ from renault_api.const import AVAILABLE_LOCALES from renault_api.gigya.exceptions import GigyaException import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, DOMAIN @@ -46,6 +50,7 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): Ask the user for API keys. """ errors: dict[str, str] = {} + suggested_values: Mapping[str, Any] | None = None if user_input: locale = user_input[CONF_LOCALE] self.renault_config.update(user_input) @@ -64,9 +69,15 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): if login_success: return await self.async_step_kamereon() errors["base"] = "invalid_credentials" + suggested_values = user_input + elif self.source == SOURCE_RECONFIGURE: + suggested_values = self._get_reconfigure_entry().data + return self.async_show_form( step_id="user", - data_schema=USER_SCHEMA, + data_schema=self.add_suggested_values_to_schema( + USER_SCHEMA, suggested_values + ), errors=errors, ) @@ -76,6 +87,14 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): """Select Kamereon account.""" if user_input: await self.async_set_unique_id(user_input[CONF_KAMEREON_ACCOUNT_ID]) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + self.renault_config.update(user_input) + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=self.renault_config, + ) + self._abort_if_unique_id_configured() self.renault_config.update(user_input) @@ -128,3 +147,9 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]}, ) + + async def async_step_reconfigure( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + return await self.async_step_user() diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 201a07c6783..1dffededf38 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -7,7 +7,12 @@ DOMAIN = "renault" CONF_LOCALE = "locale" CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" -DEFAULT_SCAN_INTERVAL = 420 # 7 minutes +# normal number of allowed calls per hour to the API +# for a single car and the 7 coordinator, it is a scan every 7mn +MAX_CALLS_PER_HOURS = 60 + +# If throttled time to pause the updates, in seconds +COOLING_UPDATES_SECONDS = 60 * 15 # 15 minutes PLATFORMS = [ Platform.BINARY_SENSOR, diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py index a90331730bc..c768c436133 100644 --- a/homeassistant/components/renault/coordinator.py +++ b/homeassistant/components/renault/coordinator.py @@ -12,6 +12,7 @@ from renault_api.kamereon.exceptions import ( AccessDeniedException, KamereonResponseException, NotSupportedException, + QuotaLimitException, ) from renault_api.kamereon.models import KamereonVehicleDataAttributes @@ -20,6 +21,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda if TYPE_CHECKING: from . import RenaultConfigEntry + from .renault_hub import RenaultHub T = TypeVar("T", bound=KamereonVehicleDataAttributes) @@ -37,6 +39,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): self, hass: HomeAssistant, config_entry: RenaultConfigEntry, + hub: RenaultHub, logger: logging.Logger, *, name: str, @@ -54,10 +57,24 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): ) self.access_denied = False self.not_supported = False + self.assumed_state = False + self._has_already_worked = False + self._hub = hub async def _async_update_data(self) -> T: """Fetch the latest data from the source.""" + + if self._hub.is_throttled(): + if not self._has_already_worked: + raise UpdateFailed("Renault hub currently throttled: init skipped") + # we have been throttled and decided to cooldown + # so do not count this update as an error + # coordinator. last_update_success should still be ok + self.logger.debug("Renault hub currently throttled: scan skipped") + self.assumed_state = True + return self.data + try: async with _PARALLEL_SEMAPHORE: data = await self.update_method() @@ -70,6 +87,16 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): self.access_denied = True raise UpdateFailed(f"This endpoint is denied: {err}") from err + except QuotaLimitException as err: + # The data we got is not bad per see, initiate cooldown for all coordinators + self._hub.set_throttled() + if self._has_already_worked: + self.assumed_state = True + self.logger.warning("Renault API throttled") + return self.data + + raise UpdateFailed(f"Renault API throttled: {err}") from err + except NotSupportedException as err: # Disable because the vehicle does not support this Renault endpoint. self.update_interval = None @@ -81,6 +108,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): raise UpdateFailed(f"Error communicating with API: {err}") from err self._has_already_worked = True + self.assumed_state = False return data async def async_config_entry_first_refresh(self) -> None: diff --git a/homeassistant/components/renault/entity.py b/homeassistant/components/renault/entity.py index 7beb91e9603..81d81a18b7f 100644 --- a/homeassistant/components/renault/entity.py +++ b/homeassistant/components/renault/entity.py @@ -60,3 +60,8 @@ class RenaultDataEntity( def _get_data_attr(self, key: str) -> StateType: """Return the attribute value from the coordinator data.""" return cast(StateType, getattr(self.coordinator.data, key)) + + @property + def assumed_state(self) -> bool: + """Return True if unable to access real state of the entity.""" + return self.coordinator.assumed_state diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 1a599afe4e4..2861c52c24a 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.9"] + "requirements": ["renault-api==0.3.1"] } diff --git a/homeassistant/components/renault/quality_scale.yaml b/homeassistant/components/renault/quality_scale.yaml index f2d70622192..84a7e352cbc 100644 --- a/homeassistant/components/renault/quality_scale.yaml +++ b/homeassistant/components/renault/quality_scale.yaml @@ -40,21 +40,21 @@ rules: discovery: status: exempt comment: Discovery not possible - docs-data-update: todo + docs-data-update: done docs-examples: todo - docs-known-limitations: todo + docs-known-limitations: done docs-supported-devices: todo docs-supported-functions: todo - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: todo dynamic-devices: todo entity-category: done entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: done stale-devices: done diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index b37390526cf..1f883435dee 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -27,8 +27,14 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession if TYPE_CHECKING: from . import RenaultConfigEntry -from .const import CONF_KAMEREON_ACCOUNT_ID, DEFAULT_SCAN_INTERVAL -from .renault_vehicle import RenaultVehicleProxy +from time import time + +from .const import ( + CONF_KAMEREON_ACCOUNT_ID, + COOLING_UPDATES_SECONDS, + MAX_CALLS_PER_HOURS, +) +from .renault_vehicle import COORDINATORS, RenaultVehicleProxy LOGGER = logging.getLogger(__name__) @@ -45,6 +51,24 @@ class RenaultHub: self._account: RenaultAccount | None = None self._vehicles: dict[str, RenaultVehicleProxy] = {} + self._got_throttled_at_time: float | None = None + + def set_throttled(self) -> None: + """We got throttled, we need to adjust the rate limit.""" + if self._got_throttled_at_time is None: + self._got_throttled_at_time = time() + + def is_throttled(self) -> bool: + """Check if we are throttled.""" + if self._got_throttled_at_time is None: + return False + + if time() - self._got_throttled_at_time > COOLING_UPDATES_SECONDS: + self._got_throttled_at_time = None + return False + + return True + async def attempt_login(self, username: str, password: str) -> bool: """Attempt login to Renault servers.""" try: @@ -58,7 +82,6 @@ class RenaultHub: async def async_initialise(self, config_entry: RenaultConfigEntry) -> None: """Set up proxy.""" account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID] - scan_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL) self._account = await self._client.get_api_account(account_id) vehicles = await self._account.get_vehicles() @@ -70,6 +93,12 @@ class RenaultHub: raise ConfigEntryNotReady( "Failed to retrieve vehicle details from Renault servers" ) + + num_call_per_scan = len(COORDINATORS) * len(vehicles.vehicleLinks) + scan_interval = timedelta( + seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS + ) + device_registry = dr.async_get(self._hass) await asyncio.gather( *( @@ -84,6 +113,21 @@ class RenaultHub: ) ) + # all vehicles have been initiated with the right number of active coordinators + num_call_per_scan = 0 + for vehicle_link in vehicles.vehicleLinks: + vehicle = self._vehicles[str(vehicle_link.vin)] + num_call_per_scan += len(vehicle.coordinators) + + new_scan_interval = timedelta( + seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS + ) + if new_scan_interval != scan_interval: + # we need to change the vehicles with the right scan interval + for vehicle_link in vehicles.vehicleLinks: + vehicle = self._vehicles[str(vehicle_link.vin)] + vehicle.update_scan_interval(new_scan_interval) + async def async_initialise_vehicle( self, vehicle_link: KamereonVehiclesLink, @@ -99,6 +143,7 @@ class RenaultHub: vehicle = RenaultVehicleProxy( hass=self._hass, config_entry=config_entry, + hub=self, vehicle=await renault_account.get_api_vehicle(vehicle_link.vin), details=vehicle_link.vehicleDetails, scan_interval=scan_interval, diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 1cce0e4459f..89059e890f4 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -11,7 +11,7 @@ import logging from typing import TYPE_CHECKING, Any, Concatenate, cast from renault_api.exceptions import RenaultException -from renault_api.kamereon import models +from renault_api.kamereon import models, schemas from renault_api.renault_vehicle import RenaultVehicle from homeassistant.core import HomeAssistant @@ -20,6 +20,7 @@ from homeassistant.helpers.device_registry import DeviceInfo if TYPE_CHECKING: from . import RenaultConfigEntry + from .renault_hub import RenaultHub from .const import DOMAIN from .coordinator import RenaultDataUpdateCoordinator @@ -42,7 +43,11 @@ def with_error_wrapping[**_P, _R]( try: return await func(self, *args, **kwargs) except RenaultException as err: - raise HomeAssistantError(err) from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unknown_error", + translation_placeholders={"error": str(err)}, + ) from err return wrapper @@ -68,6 +73,7 @@ class RenaultVehicleProxy: self, hass: HomeAssistant, config_entry: RenaultConfigEntry, + hub: RenaultHub, vehicle: RenaultVehicle, details: models.KamereonVehicleDetails, scan_interval: timedelta, @@ -87,6 +93,14 @@ class RenaultVehicleProxy: self.coordinators: dict[str, RenaultDataUpdateCoordinator] = {} self.hvac_target_temperature = 21 self._scan_interval = scan_interval + self._hub = hub + + def update_scan_interval(self, scan_interval: timedelta) -> None: + """Set the scan interval for the vehicle.""" + if scan_interval != self._scan_interval: + self._scan_interval = scan_interval + for coordinator in self.coordinators.values(): + coordinator.update_interval = scan_interval @property def details(self) -> models.KamereonVehicleDetails: @@ -104,6 +118,7 @@ class RenaultVehicleProxy: coord.key: RenaultDataUpdateCoordinator( self.hass, self.config_entry, + self._hub, LOGGER, name=f"{self.details.vin} {coord.key}", update_method=coord.update_method(self._vehicle), @@ -186,7 +201,18 @@ class RenaultVehicleProxy: @with_error_wrapping async def get_charging_settings(self) -> models.KamereonVehicleChargingSettingsData: """Get vehicle charging settings.""" - return await self._vehicle.get_charging_settings() + full_endpoint = await self._vehicle.get_full_endpoint("charging-settings") + response = await self._vehicle.http_get(full_endpoint) + response_data = cast( + models.KamereonVehicleDataResponse, + schemas.KamereonVehicleDataResponseSchema.load(response.raw_data), + ) + return cast( + models.KamereonVehicleChargingSettingsData, + response_data.get_attributes( + schemas.KamereonVehicleChargingSettingsDataSchema + ), + ) @with_error_wrapping async def set_charge_schedules( diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index df65d16b0b8..dfad97ae4ea 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping from datetime import datetime import logging from typing import TYPE_CHECKING, Any @@ -105,91 +104,96 @@ SERVICES = [ ] -def setup_services(hass: HomeAssistant) -> None: - """Register the Renault services.""" +async def ac_cancel(service_call: ServiceCall) -> None: + """Cancel A/C.""" + proxy = get_vehicle_proxy(service_call) - async def ac_cancel(service_call: ServiceCall) -> None: - """Cancel A/C.""" - proxy = get_vehicle_proxy(service_call.data) + LOGGER.debug("A/C cancel attempt") + result = await proxy.set_ac_stop() + LOGGER.debug("A/C cancel result: %s", result) - LOGGER.debug("A/C cancel attempt") - result = await proxy.set_ac_stop() - LOGGER.debug("A/C cancel result: %s", result) - async def ac_start(service_call: ServiceCall) -> None: - """Start A/C.""" - temperature: float = service_call.data[ATTR_TEMPERATURE] - when: datetime | None = service_call.data.get(ATTR_WHEN) - proxy = get_vehicle_proxy(service_call.data) +async def ac_start(service_call: ServiceCall) -> None: + """Start A/C.""" + temperature: float = service_call.data[ATTR_TEMPERATURE] + when: datetime | None = service_call.data.get(ATTR_WHEN) + proxy = get_vehicle_proxy(service_call) - LOGGER.debug("A/C start attempt: %s / %s", temperature, when) - result = await proxy.set_ac_start(temperature, when) - LOGGER.debug("A/C start result: %s", result.raw_data) + LOGGER.debug("A/C start attempt: %s / %s", temperature, when) + result = await proxy.set_ac_start(temperature, when) + LOGGER.debug("A/C start result: %s", result.raw_data) - async def charge_set_schedules(service_call: ServiceCall) -> None: - """Set charge schedules.""" - schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] - proxy = get_vehicle_proxy(service_call.data) - charge_schedules = await proxy.get_charging_settings() - for schedule in schedules: - charge_schedules.update(schedule) - if TYPE_CHECKING: - assert charge_schedules.schedules is not None - LOGGER.debug("Charge set schedules attempt: %s", schedules) - result = await proxy.set_charge_schedules(charge_schedules.schedules) +async def charge_set_schedules(service_call: ServiceCall) -> None: + """Set charge schedules.""" + schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] + proxy = get_vehicle_proxy(service_call) + charge_schedules = await proxy.get_charging_settings() + for schedule in schedules: + charge_schedules.update(schedule) - LOGGER.debug("Charge set schedules result: %s", result) - LOGGER.debug( - "It may take some time before these changes are reflected in your vehicle" - ) + if TYPE_CHECKING: + assert charge_schedules.schedules is not None + LOGGER.debug("Charge set schedules attempt: %s", schedules) + result = await proxy.set_charge_schedules(charge_schedules.schedules) - async def ac_set_schedules(service_call: ServiceCall) -> None: - """Set A/C schedules.""" - schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] - proxy = get_vehicle_proxy(service_call.data) - hvac_schedules = await proxy.get_hvac_settings() + LOGGER.debug("Charge set schedules result: %s", result) + LOGGER.debug( + "It may take some time before these changes are reflected in your vehicle" + ) - for schedule in schedules: - hvac_schedules.update(schedule) - if TYPE_CHECKING: - assert hvac_schedules.schedules is not None - LOGGER.debug("HVAC set schedules attempt: %s", schedules) - result = await proxy.set_hvac_schedules(hvac_schedules.schedules) +async def ac_set_schedules(service_call: ServiceCall) -> None: + """Set A/C schedules.""" + schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] + proxy = get_vehicle_proxy(service_call) + hvac_schedules = await proxy.get_hvac_settings() - LOGGER.debug("HVAC set schedules result: %s", result) - LOGGER.debug( - "It may take some time before these changes are reflected in your vehicle" - ) + for schedule in schedules: + hvac_schedules.update(schedule) - def get_vehicle_proxy(service_call_data: Mapping) -> RenaultVehicleProxy: - """Get vehicle from service_call data.""" - device_registry = dr.async_get(hass) - device_id = service_call_data[ATTR_VEHICLE] - device_entry = device_registry.async_get(device_id) - if device_entry is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_device_id", - translation_placeholders={"device_id": device_id}, - ) + if TYPE_CHECKING: + assert hvac_schedules.schedules is not None + LOGGER.debug("HVAC set schedules attempt: %s", schedules) + result = await proxy.set_hvac_schedules(hvac_schedules.schedules) - loaded_entries: list[RenaultConfigEntry] = [ - entry - for entry in hass.config_entries.async_loaded_entries(DOMAIN) - if entry.entry_id in device_entry.config_entries - ] - for entry in loaded_entries: - for vin, vehicle in entry.runtime_data.vehicles.items(): - if (DOMAIN, vin) in device_entry.identifiers: - return vehicle + LOGGER.debug("HVAC set schedules result: %s", result) + LOGGER.debug( + "It may take some time before these changes are reflected in your vehicle" + ) + + +def get_vehicle_proxy(service_call: ServiceCall) -> RenaultVehicleProxy: + """Get vehicle from service_call data.""" + device_registry = dr.async_get(service_call.hass) + device_id = service_call.data[ATTR_VEHICLE] + device_entry = device_registry.async_get(device_id) + if device_entry is None: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="no_config_entry_for_device", - translation_placeholders={"device_id": device_entry.name or device_id}, + translation_key="invalid_device_id", + translation_placeholders={"device_id": device_id}, ) + loaded_entries: list[RenaultConfigEntry] = [ + entry + for entry in service_call.hass.config_entries.async_loaded_entries(DOMAIN) + if entry.entry_id in device_entry.config_entries + ] + for entry in loaded_entries: + for vin, vehicle in entry.runtime_data.vehicles.items(): + if (DOMAIN, vin) in device_entry.identifiers: + return vehicle + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_config_entry_for_device", + translation_placeholders={"device_id": device_entry.name or device_id}, + ) + + +def setup_services(hass: HomeAssistant) -> None: + """Register the Renault services.""" + hass.services.async_register( DOMAIN, SERVICE_AC_CANCEL, diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 8649a5c7b47..dabe2f77bac 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -3,7 +3,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "kamereon_no_account": "Unable to find Kamereon 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%]", + "unique_id_mismatch": "The selected Kamereon account ID does not match the previous account ID" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -118,7 +120,7 @@ "charge_ended": "Charge ended", "waiting_for_current_charge": "Waiting for current charge", "energy_flap_opened": "Energy flap opened", - "charge_in_progress": "Charging", + "charge_in_progress": "[%key:common::state::charging%]", "charge_error": "Not charging or plugged in", "unavailable": "Unavailable" } @@ -155,7 +157,6 @@ "state": { "unplugged": "Unplugged", "plugged": "Plugged in", - "plugged_waiting_for_charge": "Plugged in, waiting for charge", "plug_error": "Plug error", "plug_unknown": "Plug unknown" } @@ -232,6 +233,9 @@ }, "no_config_entry_for_device": { "message": "No loaded config entry was found for device with ID {device_id}" + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the Renault servers: {error}" } } } diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 7d1dba099ed..7df82dfc512 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -380,6 +380,9 @@ }, "scene_mode": { "default": "mdi:view-list" + }, + "packing_time": { + "default": "mdi:record-rec" } }, "sensor": { diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index e5d66ed3901..2ee2b790687 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -263,6 +263,17 @@ HOST_SELECT_ENTITIES = ( value=lambda api: api.baichuan.active_scene, method=lambda api, name: api.baichuan.set_scene(scene_name=name), ), + ReolinkHostSelectEntityDescription( + key="packing_time", + cmd_key="GetRec", + translation_key="packing_time", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + get_options=lambda api: api.recording_packing_time_list, + supported=lambda api: api.supported(None, "pak_time"), + value=lambda api: api.recording_packing_time, + method=lambda api, value: api.set_recording_packing_time(value), + ), ) CHIME_SELECT_ENTITIES = ( diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 9a6db7b5d67..8b7d276a9e3 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -15,10 +15,10 @@ "data_description": { "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'.", "port": "The HTTP(s) port to connect to the Reolink device API. For HTTP normally: '80', for HTTPS normally '443'.", - "use_https": "Use a HTTPS (SSL) connection to the Reolink device.", + "use_https": "Use an HTTPS (SSL) connection to the Reolink device.", "baichuan_port": "The 'Basic Service Port' to connect to the Reolink device over TCP. Normally '9000' unless manually changed in the Reolink desktop client.", - "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", - "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." + "username": "Username to log in to the Reolink device itself. Not the Reolink cloud account.", + "password": "Password to log in to the Reolink device itself. Not the Reolink cloud account." } }, "privacy": { @@ -33,7 +33,7 @@ "not_admin": "User needs to be admin, user \"{username}\" has authorisation level \"{userlevel}\"", "password_incompatible": "Password contains incompatible special character or is too long, maximum 31 characters and only these characters are allowed: a-z, A-Z, 0-9 or {special_chars}", "unknown": "[%key:common::config_flow::error::unknown%]", - "update_needed": "Failed to login because of outdated firmware, please update the firmware to version {needed_firmware} using the Reolink Download Center: {download_center_url}, currently version {current_firmware} is installed", + "update_needed": "Failed to log in because of outdated firmware, please update the firmware to version {needed_firmware} using the Reolink Download Center: {download_center_url}, currently version {current_firmware} is installed", "webhook_exception": "Home Assistant URL is not available, go to Settings > System > Network > Home Assistant URL and correct the URLs, see {more_info}" }, "abort": { @@ -66,7 +66,7 @@ "message": "Invalid input parameter: {err}" }, "api_error": { - "message": "The device responded with a error: {err}" + "message": "The device responded with an error: {err}" }, "invalid_content_type": { "message": "Received a different content type than expected: {err}" @@ -130,7 +130,7 @@ }, "firmware_update": { "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})." + "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running an 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})." }, "hub_switch_deprecated": { "title": "Reolink Home Hub switches deprecated", @@ -652,7 +652,7 @@ "name": "Floodlight mode", "state": { "off": "[%key:common::state::off%]", - "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]", + "auto": "[%key:common::state::auto%]", "onatnight": "On at night", "schedule": "Schedule", "adaptive": "Adaptive", @@ -662,7 +662,7 @@ "day_night_mode": { "name": "Day night mode", "state": { - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "color": "Color", "blackwhite": "Black & white" } @@ -691,7 +691,7 @@ "name": "Doorbell LED", "state": { "stayoff": "Stay off", - "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]", + "auto": "[%key:common::state::auto%]", "alwaysonatnight": "Auto & always on at night", "always": "Always on", "alwayson": "Always on" @@ -702,7 +702,7 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", - "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]" + "auto": "[%key:common::state::auto%]" } }, "binning_mode": { @@ -710,7 +710,7 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", - "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]" + "auto": "[%key:common::state::auto%]" } }, "hub_alarm_ringtone": { @@ -842,9 +842,12 @@ "state": { "off": "[%key:common::state::off%]", "disarm": "Disarmed", - "home": "Home", - "away": "Away" + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]" } + }, + "packing_time": { + "name": "Recording packing time" } }, "sensor": { @@ -893,7 +896,7 @@ }, "switch": { "ir_lights": { - "name": "Infra red lights in night mode" + "name": "Infrared lights in night mode" }, "record_audio": { "name": "Record audio" diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index fa5bd388009..2e73f1b1b82 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -32,6 +32,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, ManualTriggerEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -132,7 +133,7 @@ class RestBinarySensor(ManualTriggerEntity, RestEntity, BinarySensorEntity): config[CONF_FORCE_UPDATE], ) self._previous_data = None - self._value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) + self._value_template: ValueTemplate | None = config.get(CONF_VALUE_TEMPLATE) @property def available(self) -> bool: @@ -156,11 +157,14 @@ class RestBinarySensor(ManualTriggerEntity, RestEntity, BinarySensorEntity): ) return - raw_value = response + variables = self._template_variables_with_value(response) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return if response is not None and self._value_template is not None: - response = self._value_template.async_render_with_possible_json_value( - response, False + response = self._value_template.async_render_as_value_template( + self.entity_id, variables, False ) try: @@ -173,5 +177,5 @@ class RestBinarySensor(ManualTriggerEntity, RestEntity, BinarySensorEntity): "yes": True, }.get(str(response).lower(), False) - self._process_manual_data(raw_value) + self._process_manual_data(variables) self.async_write_ha_state() diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index 62ed2d5c5b2..bddad18586e 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -31,6 +31,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, TEMPLATE_ENTITY_BASE_SCHEMA, TEMPLATE_SENSOR_BASE_SCHEMA, + ValueTemplate, ) from homeassistant.util.ssl import SSLCipherList @@ -76,7 +77,9 @@ SENSOR_SCHEMA = { **TEMPLATE_SENSOR_BASE_SCHEMA.schema, vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, vol.Optional(CONF_JSON_ATTRS_PATH): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, vol.Optional(CONF_AVAILABILITY): cv.template, } @@ -84,7 +87,9 @@ SENSOR_SCHEMA = { BINARY_SENSOR_SCHEMA = { **TEMPLATE_ENTITY_BASE_SCHEMA.schema, vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, vol.Optional(CONF_AVAILABILITY): cv.template, } diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index b95e6dd72b7..9df10197a1a 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -36,6 +36,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, ManualTriggerSensorEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -138,7 +139,7 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): config.get(CONF_RESOURCE_TEMPLATE), config[CONF_FORCE_UPDATE], ) - self._value_template = config.get(CONF_VALUE_TEMPLATE) + self._value_template: ValueTemplate | None = config.get(CONF_VALUE_TEMPLATE) self._json_attrs = config.get(CONF_JSON_ATTRS) self._json_attrs_path = config.get(CONF_JSON_ATTRS_PATH) self._attr_extra_state_attributes = {} @@ -165,16 +166,19 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): ) value = self.rest.data + variables = self._template_variables_with_value(value) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return + if self._json_attrs: self._attr_extra_state_attributes = parse_json_attributes( value, self._json_attrs, self._json_attrs_path ) - raw_value = value - if value is not None and self._value_template is not None: - value = self._value_template.async_render_with_possible_json_value( - value, None + value = self._value_template.async_render_as_value_template( + self.entity_id, variables, None ) if value is None or self.device_class not in ( @@ -182,7 +186,7 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): SensorDeviceClass.TIMESTAMP, ): self._attr_native_value = value - self._process_manual_data(raw_value) + self._process_manual_data(variables) self.async_write_ha_state() return @@ -190,5 +194,5 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): value, self.entity_id, self.device_class ) - self._process_manual_data(raw_value) + self._process_manual_data(variables) self.async_write_ha_state() diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index e4bb1f797d9..4f16503a2ea 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -38,6 +38,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_PICTURE, TEMPLATE_ENTITY_BASE_SCHEMA, ManualTriggerEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -73,7 +74,9 @@ PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( vol.Optional(CONF_PARAMS): {cv.string: cv.template}, vol.Optional(CONF_BODY_OFF, default=DEFAULT_BODY_OFF): cv.template, vol.Optional(CONF_BODY_ON, default=DEFAULT_BODY_ON): cv.template, - vol.Optional(CONF_IS_ON_TEMPLATE): cv.template, + vol.Optional(CONF_IS_ON_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.All( vol.Lower, vol.In(SUPPORT_REST_METHODS) ), @@ -107,7 +110,7 @@ async def async_setup_platform( try: switch = RestSwitch(hass, config, trigger_entity_config) - req = await switch.get_device_state(hass) + req = await switch.get_response(hass) if req.status_code >= HTTPStatus.BAD_REQUEST: _LOGGER.error("Got non-ok response from resource: %s", req.status_code) else: @@ -147,7 +150,7 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): self._auth = auth self._body_on: template.Template = config[CONF_BODY_ON] self._body_off: template.Template = config[CONF_BODY_OFF] - self._is_on_template: template.Template | None = config.get(CONF_IS_ON_TEMPLATE) + self._is_on_template: ValueTemplate | None = config.get(CONF_IS_ON_TEMPLATE) self._timeout: int = config[CONF_TIMEOUT] self._verify_ssl: bool = config[CONF_VERIFY_SSL] @@ -208,35 +211,41 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): """Get the current state, catching errors.""" req = None try: - req = await self.get_device_state(self.hass) + req = await self.get_response(self.hass) except (TimeoutError, httpx.TimeoutException): _LOGGER.exception("Timed out while fetching data") except httpx.RequestError: _LOGGER.exception("Error while fetching data") if req: - self._process_manual_data(req.text) - self.async_write_ha_state() + self._async_update(req.text) - async def get_device_state(self, hass: HomeAssistant) -> httpx.Response: + async def get_response(self, hass: HomeAssistant) -> httpx.Response: """Get the latest data from REST API and update the state.""" websession = get_async_client(hass, self._verify_ssl) rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) - req = await websession.get( + return await websession.get( self._state_resource, auth=self._auth, headers=rendered_headers, params=rendered_params, timeout=self._timeout, ) - text = req.text + + def _async_update(self, text: str) -> None: + """Get the latest data from REST API and update the state.""" + + variables = self._template_variables_with_value(text) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return if self._is_on_template is not None: - text = self._is_on_template.async_render_with_possible_json_value( - text, "None" + text = self._is_on_template.async_render_as_value_template( + self.entity_id, variables, "None" ) text = text.lower() if text == "true": @@ -252,4 +261,5 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): else: self._attr_is_on = None - return req + self._process_manual_data(variables) + self.async_write_ha_state() diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index db4efad5bb4..d3b65dc238a 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -48,7 +48,7 @@ "event_code": "Enter event code to add", "device": "Select device to configure" }, - "title": "Rfxtrx Options" + "title": "RFXtrx options" }, "set_device_options": { "data": { @@ -105,15 +105,15 @@ "sound_15": "Sound 15", "down": "Down", "up": "Up", - "all_off": "All Off", - "all_on": "All On", + "all_off": "All off", + "all_on": "All on", "scene": "Scene", - "off": "Off", - "on": "On", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", "dim": "Dim", "bright": "Bright", - "all_group_off": "All/group Off", - "all_group_on": "All/group On", + "all_group_off": "All/group off", + "all_group_on": "All/group on", "chime": "Chime", "illegal_command": "Illegal command", "set_level": "Set level", @@ -131,40 +131,40 @@ "level_9": "Level 9", "program": "Program", "stop": "Stop", - "0_5_seconds_up": "0.5 Seconds Up", - "0_5_seconds_down": "0.5 Seconds Down", - "2_seconds_up": "2 Seconds Up", - "2_seconds_down": "2 Seconds Down", + "0_5_seconds_up": "0.5 seconds up", + "0_5_seconds_down": "0.5 seconds down", + "2_seconds_up": "2 seconds up", + "2_seconds_down": "2 seconds down", "enable_sun_automation": "Enable sun automation", "disable_sun_automation": "Disable sun automation", - "normal": "Normal", - "normal_delayed": "Normal Delayed", + "normal": "[%key:common::state::normal%]", + "normal_delayed": "Normal delayed", "alarm": "Alarm", - "alarm_delayed": "Alarm Delayed", + "alarm_delayed": "Alarm delayed", "motion": "Motion", - "no_motion": "No Motion", + "no_motion": "No motion", "panic": "Panic", - "end_panic": "End Panic", + "end_panic": "End panic", "ir": "IR", - "arm_away": "Arm Away", - "arm_away_delayed": "Arm Away Delayed", - "arm_home": "Arm Home", - "arm_home_delayed": "Arm Home Delayed", + "arm_away": "Arm away", + "arm_away_delayed": "Arm away delayed", + "arm_home": "Arm home", + "arm_home_delayed": "Arm home delayed", "disarm": "Disarm", - "light_1_off": "Light 1 Off", - "light_1_on": "Light 1 On", - "light_2_off": "Light 2 Off", - "light_2_on": "Light 2 On", - "dark_detected": "Dark Detected", - "light_detected": "Light Detected", + "light_1_off": "Light 1 off", + "light_1_on": "Light 1 on", + "light_2_off": "Light 2 off", + "light_2_on": "Light 2 on", + "dark_detected": "Dark detected", + "light_detected": "Light detected", "battery_low": "Battery low", "pairing_kd101": "Pairing KD101", - "normal_tamper": "Normal Tamper", - "normal_delayed_tamper": "Normal Delayed Tamper", - "alarm_tamper": "Alarm Tamper", - "alarm_delayed_tamper": "Alarm Delayed Tamper", - "motion_tamper": "Motion Tamper", - "no_motion_tamper": "No Motion Tamper" + "normal_tamper": "Normal tamper", + "normal_delayed_tamper": "Normal delayed tamper", + "alarm_tamper": "Alarm tamper", + "alarm_delayed_tamper": "Alarm delayed tamper", + "motion_tamper": "Motion tamper", + "no_motion_tamper": "No motion tamper" } } } diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 8140b58b86c..81b412c6770 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -164,6 +164,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> return True +async def async_migrate_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: + """Migrate old configuration entries to the new format.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + entry.version, + entry.minor_version, + ) + if entry.version > 1: + # Downgrade from future version + return False + + # 1->2: Migrate from unique id as email address to unique id as rruid + if entry.minor_version == 1: + user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) + _LOGGER.debug("Updating unique id to %s", user_data.rruid) + hass.config_entries.async_update_entry( + entry, + unique_id=user_data.rruid, + version=1, + minor_version=2, + ) + + return True + + def build_setup_functions( hass: HomeAssistant, entry: RoborockConfigEntry, diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 886bebea9b6..62943e0dcc9 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -48,6 +48,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Roborock.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the config flow.""" @@ -62,8 +63,6 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): 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(error="already_configured_account") self._username = username _LOGGER.debug("Requesting code for Roborock account") self._client = RoborockApiClient( @@ -111,7 +110,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): code = user_input[CONF_ENTRY_CODE] _LOGGER.debug("Logging into Roborock account using email provided code") try: - login_data = await self._client.code_login(code) + user_data = await self._client.code_login(code) except RoborockInvalidCode: errors["base"] = "invalid_code" except RoborockException: @@ -121,17 +120,20 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: + await self.async_set_unique_id(user_data.rruid) if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="wrong_account") reauth_entry = self._get_reauth_entry() self.hass.config_entries.async_update_entry( reauth_entry, data={ **reauth_entry.data, - CONF_USER_DATA: login_data.as_dict(), + CONF_USER_DATA: user_data.as_dict(), }, ) return self.async_abort(reason="reauth_successful") - return self._create_entry(self._client, self._username, login_data) + self._abort_if_unique_id_configured(error="already_configured_account") + return self._create_entry(self._client, self._username, user_data) return self.async_show_form( step_id="code", diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 531590d5d6e..444232b5843 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -19,7 +19,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==2.16.1", - "vacuum-map-parser-roborock==0.1.2" + "python-roborock==2.18.2", + "vacuum-map-parser-roborock==0.1.4" ] } diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index caad67e4ce6..2d1fcebd9d3 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -35,7 +35,8 @@ }, "abort": { "already_configured_account": "[%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%]", + "wrong_account": "Wrong account: Please authenticate with the right account." } }, "options": { @@ -155,10 +156,10 @@ "ready": "Ready", "charging": "[%key:common::state::charging%]", "mop_washing": "Washing mop", - "self_clean_cleaning": "Self clean cleaning", - "self_clean_deep_cleaning": "Self clean deep cleaning", - "self_clean_rinsing": "Self clean rinsing", - "self_clean_dehydrating": "Self clean drying", + "self_clean_cleaning": "Self-clean cleaning", + "self_clean_deep_cleaning": "Self-clean deep cleaning", + "self_clean_rinsing": "Self-clean rinsing", + "self_clean_dehydrating": "Self-clean drying", "drying": "Drying", "ventilating": "Ventilating", "reserving": "Reserving", @@ -231,7 +232,7 @@ "charging_problem": "Charging problem", "paused": "[%key:common::state::paused%]", "spot_cleaning": "Spot cleaning", - "error": "Error", + "error": "[%key:common::state::error%]", "shutting_down": "Shutting down", "updating": "Updating", "docking": "Docking", @@ -338,7 +339,7 @@ "zeo_state": { "name": "State", "state": { - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "weighing": "Weighing", "soaking": "Soaking", "washing": "Washing", @@ -367,12 +368,12 @@ "name": "Mop intensity", "state": { "off": "[%key:common::state::off%]", - "low": "Low", + "low": "[%key:common::state::low%]", "mild": "Mild", - "medium": "Medium", + "medium": "[%key:common::state::medium%]", "moderate": "Moderate", "max": "Max", - "high": "High", + "high": "[%key:common::state::high%]", "intense": "Intense", "custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]", "custom_water_flow": "Custom water flow", @@ -425,14 +426,14 @@ "state_attributes": { "fan_speed": { "state": { - "auto": "Auto", + "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", "balanced": "Balanced", "custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]", "gentle": "Gentle", - "off": "[%key:common::state::off%]", "max": "[%key:component::roborock::entity::select::mop_intensity::state::max%]", "max_plus": "Max plus", - "medium": "Medium", + "medium": "[%key:common::state::medium%]", "quiet": "Quiet", "silent": "Silent", "standard": "[%key:component::roborock::entity::select::mop_mode::state::standard%]", diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json index 04348bc3bfb..62f1f8b1736 100644 --- a/homeassistant/components/roku/strings.json +++ b/homeassistant/components/roku/strings.json @@ -47,7 +47,7 @@ "name": "Supports AirPlay" }, "supports_ethernet": { - "name": "Supports ethernet" + "name": "Supports Ethernet" }, "supports_find_remote": { "name": "Supports find remote" diff --git a/homeassistant/components/romy/strings.json b/homeassistant/components/romy/strings.json index 78721da17ba..aa7bfe26ea0 100644 --- a/homeassistant/components/romy/strings.json +++ b/homeassistant/components/romy/strings.json @@ -21,7 +21,7 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "password": "(8 characters, see QR Code under the dustbin)." + "password": "(8 characters, see QR code under the dustbin)." } }, "zeroconf_confirm": { @@ -36,12 +36,12 @@ "fan_speed": { "state": { "default": "Default", - "normal": "Normal", - "silent": "Silent", + "auto": "[%key:common::state::auto%]", + "normal": "[%key:common::state::normal%]", + "high": "[%key:common::state::high%]", "intensive": "Intensive", - "super_silent": "Super silent", - "high": "High", - "auto": "Auto" + "silent": "Silent", + "super_silent": "Super silent" } } } diff --git a/homeassistant/components/route53/manifest.json b/homeassistant/components/route53/manifest.json index 978c916e3ee..8c21b856b80 100644 --- a/homeassistant/components/route53/manifest.json +++ b/homeassistant/components/route53/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["boto3", "botocore", "s3transfer"], "quality_scale": "legacy", - "requirements": ["boto3==1.34.131"] + "requirements": ["boto3==1.37.1"] } diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index f91406e8a4b..e16e589e648 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.4.0"], + "requirements": ["aiorussound==4.5.2"], "zeroconf": ["_rio._tcp.local."] } diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index e416cd35765..e306e00691f 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -10,7 +10,6 @@ from urllib.parse import urlparse import getmac from homeassistant.components import ssdp -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_MAC, @@ -36,6 +35,7 @@ from .const import ( CONF_SESSION_ID, CONF_SSDP_MAIN_TV_AGENT_LOCATION, CONF_SSDP_RENDERING_CONTROL_LOCATION, + DOMAIN, ENTRY_RELOAD_COOLDOWN, LEGACY_PORT, LOGGER, @@ -66,7 +66,7 @@ def _async_get_device_bridge( class DebouncedEntryReloader: """Reload only after the timer expires.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: SamsungTVConfigEntry) -> None: """Init the debounced entry reloader.""" self.hass = hass self.entry = entry @@ -79,7 +79,9 @@ class DebouncedEntryReloader: function=self._async_reload_entry, ) - async def async_call(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + async def async_call( + self, hass: HomeAssistant, entry: SamsungTVConfigEntry + ) -> None: """Start the countdown for a reload.""" if (new_token := entry.data.get(CONF_TOKEN)) != self.token: LOGGER.debug("Skipping reload as its a token update") @@ -99,7 +101,9 @@ class DebouncedEntryReloader: await self.hass.config_entries.async_reload(self.entry.entry_id) -async def _async_update_ssdp_locations(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_ssdp_locations( + hass: HomeAssistant, entry: SamsungTVConfigEntry +) -> None: """Update ssdp locations from discovery cache.""" updates = {} for ssdp_st, key in ( @@ -123,7 +127,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SamsungTVConfigEntry) -> if entry.data.get(CONF_METHOD) == METHOD_ENCRYPTED_WEBSOCKET: if not entry.data.get(CONF_TOKEN) or not entry.data.get(CONF_SESSION_ID): raise ConfigEntryAuthFailed( - "Token and session id are required in encrypted mode" + translation_domain=DOMAIN, translation_key="encrypted_mode_auth_failed" ) bridge = await _async_create_bridge_with_updated_data(hass, entry) @@ -171,7 +175,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SamsungTVConfigEntry) -> async def _async_create_bridge_with_updated_data( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: SamsungTVConfigEntry ) -> SamsungTVBridge: """Create a bridge object and update any missing data in the config entry.""" updated_data: dict[str, str | int] = {} @@ -192,7 +196,8 @@ async def _async_create_bridge_with_updated_data( load_info_attempted = True if not port or not method: raise ConfigEntryNotReady( - "Failed to determine connection method, make sure the device is on." + translation_domain=DOMAIN, + translation_key="failed_to_determine_connection_method", ) LOGGER.debug("Updated port to %s and method to %s for %s", port, method, host) @@ -258,7 +263,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: SamsungTVConfigEntry) - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: SamsungTVConfigEntry +) -> bool: """Migrate old entry.""" version = config_entry.version minor_version = config_entry.minor_version diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index b4d060372e6..e782b1dfcd9 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -150,7 +150,7 @@ class SamsungTVBridge(ABC): ) -> SamsungTVBridge: """Get Bridge instance.""" if method == METHOD_LEGACY or port == LEGACY_PORT: - return SamsungTVLegacyBridge(hass, method, host, port) + return SamsungTVLegacyBridge(hass, method, host, port or LEGACY_PORT) if method == METHOD_ENCRYPTED_WEBSOCKET or port == ENCRYPTED_WEBSOCKET_PORT: return SamsungTVEncryptedBridge(hass, method, host, port, entry_data) return SamsungTVWSBridge(hass, method, host, port, entry_data) @@ -262,14 +262,14 @@ class SamsungTVLegacyBridge(SamsungTVBridge): self, hass: HomeAssistant, method: str, host: str, port: int | None ) -> None: """Initialize Bridge.""" - super().__init__(hass, method, host, LEGACY_PORT) + super().__init__(hass, method, host, port) self.config = { CONF_NAME: VALUE_CONF_NAME, CONF_DESCRIPTION: VALUE_CONF_NAME, CONF_ID: VALUE_CONF_ID, CONF_HOST: host, CONF_METHOD: method, - CONF_PORT: None, + CONF_PORT: port, CONF_TIMEOUT: 1, } self._remote: Remote | None = None @@ -301,7 +301,7 @@ class SamsungTVLegacyBridge(SamsungTVBridge): CONF_ID: VALUE_CONF_ID, CONF_HOST: self.host, CONF_METHOD: self.method, - CONF_PORT: None, + CONF_PORT: self.port, # We need this high timeout because waiting for auth popup # is just an open socket CONF_TIMEOUT: TIMEOUT_REQUEST, @@ -510,6 +510,7 @@ class SamsungTVWSBridge( async def async_try_connect(self) -> str: """Try to connect to the Websocket TV.""" + temp_result = None for self.port in WEBSOCKET_PORTS: config = { CONF_NAME: VALUE_CONF_NAME, @@ -521,7 +522,6 @@ class SamsungTVWSBridge( CONF_TIMEOUT: TIMEOUT_REQUEST, } - result = None try: LOGGER.debug("Try config: %s", config) async with SamsungTVWSAsyncRemote( @@ -545,38 +545,43 @@ class SamsungTVWSBridge( config, err, ) - result = RESULT_NOT_SUPPORTED + temp_result = RESULT_NOT_SUPPORTED except WebSocketException as err: LOGGER.debug( "Working but unsupported config: %s, error: %s", config, err ) - result = RESULT_NOT_SUPPORTED + temp_result = RESULT_NOT_SUPPORTED except UnauthorizedError as err: LOGGER.debug("Failing config: %s, %s error: %s", config, type(err), err) return RESULT_AUTH_MISSING except (ConnectionFailure, OSError, AsyncioTimeoutError) as err: LOGGER.debug("Failing config: %s, %s error: %s", config, type(err), err) - else: # noqa: PLW0120 - if result: - return result - return RESULT_CANNOT_CONNECT + return temp_result or RESULT_CANNOT_CONNECT async def async_device_info(self, force: bool = False) -> dict[str, Any] | None: """Try to gather infos of this TV.""" if self._rest_api is None: assert self.port - rest_api = SamsungTVAsyncRest( + self._rest_api = SamsungTVAsyncRest( host=self.host, session=async_get_clientsession(self.hass), port=self.port, timeout=TIMEOUT_WEBSOCKET, ) - with contextlib.suppress(*REST_EXCEPTIONS): - device_info: dict[str, Any] = await rest_api.rest_device_info() + try: + device_info: dict[str, Any] = await self._rest_api.rest_device_info() LOGGER.debug("Device info on %s is: %s", self.host, device_info) self._device_info = device_info + except REST_EXCEPTIONS as err: + LOGGER.debug( + "Failed to load device info from %s:%s: %s", + self.host, + self.port, + str(err), + ) + else: return device_info return None if force else self._device_info diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 3f34520e87a..74915c9251b 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -24,6 +24,7 @@ from homeassistant.const import ( CONF_METHOD, CONF_MODEL, CONF_NAME, + CONF_PIN, CONF_PORT, CONF_TOKEN, ) @@ -314,7 +315,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: if ( - (pin := user_input.get("pin")) + (pin := user_input.get(CONF_PIN)) and (token := await self._authenticator.try_pin(pin)) and (session_id := await self._authenticator.get_session_id_and_close()) ): @@ -333,7 +334,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): step_id="encrypted_pairing", errors=errors, description_placeholders={"device": self._title}, - data_schema=vol.Schema({vol.Required("pin"): str}), + data_schema=vol.Schema({vol.Required(CONF_PIN): str}), ) @callback @@ -596,7 +597,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: if ( - (pin := user_input.get("pin")) + (pin := user_input.get(CONF_PIN)) and (token := await self._authenticator.try_pin(pin)) and (session_id := await self._authenticator.get_session_id_and_close()) ): @@ -615,5 +616,5 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reauth_confirm_encrypted", errors=errors, description_placeholders={"device": self._title}, - data_schema=vol.Schema({vol.Required("pin"): str}), + data_schema=vol.Schema({vol.Required(CONF_PIN): str}), ) diff --git a/homeassistant/components/samsungtv/coordinator.py b/homeassistant/components/samsungtv/coordinator.py index 443e62b13fb..ed3c24946ab 100644 --- a/homeassistant/components/samsungtv/coordinator.py +++ b/homeassistant/components/samsungtv/coordinator.py @@ -44,7 +44,7 @@ class SamsungTVDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Fetch data from SamsungTV bridge.""" - if self.bridge.auth_failed or self.hass.is_stopping: + if self.bridge.auth_failed: return old_state = self.is_on if self.bridge.power_off_in_progress: diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index f3ecee373e3..2126dae82f4 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from wakeonlan import send_magic_packet from homeassistant.const import ( @@ -82,12 +84,12 @@ class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity) # broadcast a packet as well send_magic_packet(self._mac) - async def _async_turn_off(self) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._bridge.async_power_off() await self.coordinator.async_refresh() - async def _async_turn_on(self) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the remote on.""" if self._turn_on_action: LOGGER.debug("Attempting to turn on %s via automation", self.entity_id) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 4e6ecfd3593..4fb2e6bd1a2 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -59,6 +59,9 @@ SUPPORT_SAMSUNGTV = ( # Max delay waiting for app_list to return, as some TVs simply ignore the request APP_LIST_DELAY = 3 +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -99,8 +102,6 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): if self._ssdp_rendering_control_location: self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_SET - self._bridge.register_app_list_callback(self._app_list_callback) - self._dmr_device: DmrDevice | None = None self._upnp_server: AiohttpNotifyServer | None = None @@ -127,8 +128,11 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() + + self._bridge.register_app_list_callback(self._app_list_callback) await self._async_extra_update() self.coordinator.async_extra_update = self._async_extra_update + if self.coordinator.is_on: self._attr_state = MediaPlayerState.ON self._update_from_upnp() @@ -296,10 +300,6 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): return await self._bridge.async_send_keys(keys) - async def async_turn_off(self) -> None: - """Turn off media player.""" - await super()._async_turn_off() - async def async_set_volume_level(self, volume: float) -> None: """Set volume level on the media player.""" if (dmr_device := self._dmr_device) is None: @@ -370,10 +370,6 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): keys=[f"KEY_{digit}" for digit in media_id] + ["KEY_ENTER"] ) - async def async_turn_on(self) -> None: - """Turn the media player on.""" - await super()._async_turn_on() - async def async_select_source(self, source: str) -> None: """Select input source.""" if self._app_list and source in self._app_list: diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py index d6fef262d91..ec2e8c45963 100644 --- a/homeassistant/components/samsungtv/remote.py +++ b/homeassistant/components/samsungtv/remote.py @@ -13,6 +13,9 @@ from .const import LOGGER from .coordinator import SamsungTVConfigEntry from .entity import SamsungTVEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -35,10 +38,6 @@ class SamsungTVRemote(SamsungTVEntity, RemoteEntity): self._attr_is_on = self.coordinator.is_on self.async_write_ha_state() - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" - await super()._async_turn_off() - async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: """Send a command to a device. @@ -54,7 +53,3 @@ class SamsungTVRemote(SamsungTVEntity, RemoteEntity): for _ in range(num_repeats): await self._bridge.async_send_keys(command_list) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the remote on.""" - await super()._async_turn_on() diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index 65ed7a3fc23..84e5fded03f 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -9,7 +9,8 @@ "name": "[%key:common::config_flow::data::name%]" }, "data_description": { - "host": "The hostname or IP address of your TV." + "host": "The hostname or IP address of your TV.", + "name": "The name of your TV. This will be used to identify the device in Home Assistant." } }, "confirm": { @@ -22,10 +23,22 @@ "description": "After submitting, accept the popup on {device} requesting authorization within 30 seconds or input PIN." }, "encrypted_pairing": { - "description": "Please enter the PIN displayed on {device}." + "description": "Please enter the PIN displayed on {device}.", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "The PIN displayed on your TV." + } }, "reauth_confirm_encrypted": { - "description": "[%key:component::samsungtv::config::step::encrypted_pairing::description%]" + "description": "[%key:component::samsungtv::config::step::encrypted_pairing::description%]", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "[%key:component::samsungtv::config::step::encrypted_pairing::data_description::pin%]" + } } }, "error": { @@ -54,6 +67,12 @@ }, "service_unsupported": { "message": "Entity {entity} does not support this action." + }, + "encrypted_mode_auth_failed": { + "message": "Token and session ID are required in encrypted mode." + }, + "failed_to_determine_connection_method": { + "message": "Failed to determine connection method, make sure the device is on." } } } diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 61cc2a3c63d..893c30dfd41 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2024.11.0"] + "requirements": ["pyschlage==2025.4.0"] } diff --git a/homeassistant/components/schlage/select.py b/homeassistant/components/schlage/select.py index 4648686aaac..cb142f01717 100644 --- a/homeassistant/components/schlage/select.py +++ b/homeassistant/components/schlage/select.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pyschlage.lock import AUTO_LOCK_TIMES + from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -15,16 +17,7 @@ _DESCRIPTIONS = ( key="auto_lock_time", translation_key="auto_lock_time", entity_category=EntityCategory.CONFIG, - # valid values are from Schlage UI and validated by pyschlage - options=[ - "0", - "15", - "30", - "60", - "120", - "240", - "300", - ], + options=[str(n) for n in AUTO_LOCK_TIMES], ), ) diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index 56e72c2d2c0..e37f4789580 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -33,9 +33,10 @@ }, "select": { "auto_lock_time": { - "name": "Auto-Lock time", + "name": "Auto-lock time", "state": { - "0": "Disabled", + "0": "[%key:common::state::disabled%]", + "5": "5 seconds", "15": "15 seconds", "30": "30 seconds", "60": "1 minute", diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index 68a8cf62fe4..801140157c1 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -28,6 +28,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, TEMPLATE_SENSOR_BASE_SCHEMA, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType @@ -43,7 +44,9 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_ATTRIBUTE): cv.string, vol.Optional(CONF_INDEX, default=0): cv.positive_int, vol.Required(CONF_SELECT): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), } ) diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 56b9470b4f7..28e08372d68 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/scrape", "iot_class": "cloud_polling", - "requirements": ["beautifulsoup4==4.12.3", "lxml==5.3.0"] + "requirements": ["beautifulsoup4==4.13.3", "lxml==5.3.0"] } diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index b8ad9cb8a56..80d53a2c8b1 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -25,13 +25,14 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.template import Template +from homeassistant.helpers.template import _SENTINEL, Template from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, TEMPLATE_SENSOR_BASE_SCHEMA, ManualTriggerEntity, ManualTriggerSensorEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -110,8 +111,8 @@ async def async_setup_entry( name: str = sensor_config[CONF_NAME] value_string: str | None = sensor_config.get(CONF_VALUE_TEMPLATE) - value_template: Template | None = ( - Template(value_string, hass) if value_string is not None else None + value_template: ValueTemplate | None = ( + ValueTemplate(value_string, hass) if value_string is not None else None ) trigger_entity_config: dict[str, str | Template | None] = {CONF_NAME: name} @@ -150,7 +151,7 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti select: str, attr: str | None, index: int, - value_template: Template | None, + value_template: ValueTemplate | None, yaml: bool, ) -> None: """Initialize a web scrape sensor.""" @@ -161,7 +162,6 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti self._index = index self._value_template = value_template self._attr_native_value = None - self._available = True if not yaml and (unique_id := trigger_entity_config.get(CONF_UNIQUE_ID)): self._attr_name = None self._attr_has_entity_name = True @@ -176,7 +176,6 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti """Parse the html extraction in the executor.""" raw_data = self.coordinator.data value: str | list[str] | None - self._available = True try: if self._attr is not None: value = raw_data.select(self._select)[self._index][self._attr] @@ -188,14 +187,12 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti value = tag.text except IndexError: _LOGGER.warning("Index '%s' not found in %s", self._index, self.entity_id) - value = None - self._available = False + return _SENTINEL except KeyError: _LOGGER.warning( "Attribute '%s' not found in %s", self._attr, self.entity_id ) - value = None - self._available = False + return _SENTINEL _LOGGER.debug("Parsed value: %s", value) return value @@ -207,26 +204,32 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti def _async_update_from_rest_data(self) -> None: """Update state from the rest data.""" - value = self._extract_value() - raw_value = value + self._attr_available = True + if (value := self._extract_value()) is _SENTINEL: + self._attr_available = False + return + + variables = self._template_variables_with_value(value) + if not self._render_availability_template(variables): + return if (template := self._value_template) is not None: - value = template.async_render_with_possible_json_value(value, None) + value = template.async_render_as_value_template( + self.entity_id, variables, None + ) if self.device_class not in { SensorDeviceClass.DATE, SensorDeviceClass.TIMESTAMP, }: self._attr_native_value = value - self._attr_available = self._available - self._process_manual_data(raw_value) + self._process_manual_data(variables) return self._attr_native_value = async_parse_date_datetime( value, self.entity_id, self.device_class ) - self._attr_available = self._available - self._process_manual_data(raw_value) + self._process_manual_data(variables) @property def available(self) -> bool: diff --git a/homeassistant/components/script/blueprints/confirmable_notification.yaml b/homeassistant/components/script/blueprints/confirmable_notification.yaml index c5f42494f02..0106a4e16c5 100644 --- a/homeassistant/components/script/blueprints/confirmable_notification.yaml +++ b/homeassistant/components/script/blueprints/confirmable_notification.yaml @@ -71,11 +71,11 @@ sequence: title: !input dismiss_text - alias: "Awaiting response" wait_for_trigger: - - platform: event + - trigger: event event_type: mobile_app_notification_action event_data: action: "{{ action_confirm }}" - - platform: event + - trigger: event event_type: mobile_app_notification_action event_data: action: "{{ action_dismiss }}" diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 0fbcda461c8..4dce104d1c7 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -115,7 +115,7 @@ "sensitivity": { "name": "Pure sensitivity", "state": { - "n": "Normal", + "n": "[%key:common::state::normal%]", "s": "Sensitive" } }, @@ -139,11 +139,11 @@ "fanlevel": { "name": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::name%]", "state": { - "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", - "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", - "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "auto": "[%key:common::state::auto%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "medium_low": "Medium low", - "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", + "medium": "[%key:common::state::medium%]", "medium_high": "Medium high", "strong": "Strong", "quiet": "Quiet" @@ -175,10 +175,10 @@ "name": "Mode", "state": { "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", "heat": "[%key:component::climate::entity_component::_::state::heat%]", "cool": "[%key:component::climate::entity_component::_::state::cool%]", "heat_cool": "[%key:component::climate::entity_component::_::state::heat_cool%]", - "auto": "[%key:component::climate::entity_component::_::state::auto%]", "dry": "[%key:component::climate::entity_component::_::state::dry%]", "fan_only": "[%key:component::climate::entity_component::_::state::fan_only%]" } @@ -225,11 +225,11 @@ "fanlevel": { "name": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::name%]", "state": { - "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", - "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", - "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "auto": "[%key:common::state::auto%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "medium_low": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_low%]", - "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", + "medium": "[%key:common::state::medium%]", "medium_high": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_high%]", "strong": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::strong%]", "quiet": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::quiet%]" @@ -261,10 +261,10 @@ "name": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::mode::name%]", "state": { "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", "heat": "[%key:component::climate::entity_component::_::state::heat%]", "cool": "[%key:component::climate::entity_component::_::state::cool%]", "heat_cool": "[%key:component::climate::entity_component::_::state::heat_cool%]", - "auto": "[%key:component::climate::entity_component::_::state::auto%]", "dry": "[%key:component::climate::entity_component::_::state::dry%]", "fan_only": "[%key:component::climate::entity_component::_::state::fan_only%]" } @@ -364,12 +364,12 @@ "state": { "quiet": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::quiet%]", "strong": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::strong%]", - "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "low": "[%key:common::state::low%]", "medium_low": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_low%]", - "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", + "medium": "[%key:common::state::medium%]", "medium_high": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_high%]", - "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", - "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]" + "high": "[%key:common::state::high%]", + "auto": "[%key:common::state::auto%]" } }, "swing_mode": { @@ -524,7 +524,7 @@ "selector": { "sensitivity": { "options": { - "normal": "[%key:component::sensibo::entity::sensor::sensitivity::state::n%]", + "normal": "[%key:common::state::normal%]", "sensitive": "[%key:component::sensibo::entity::sensor::sensitivity::state::s%]" } }, @@ -536,12 +536,12 @@ }, "hvac_mode": { "options": { + "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", "cool": "[%key:component::climate::entity_component::_::state::cool%]", "heat": "[%key:component::climate::entity_component::_::state::heat%]", "fan": "[%key:component::climate::entity_component::_::state::fan_only%]", - "auto": "[%key:component::climate::entity_component::_::state::auto%]", - "dry": "[%key:component::climate::entity_component::_::state::dry%]", - "off": "[%key:common::state::off%]" + "dry": "[%key:component::climate::entity_component::_::state::dry%]" } }, "light_mode": { diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 63af8e5bf52..c845980e9df 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -352,7 +352,7 @@ class SensorDeviceClass(StrEnum): REACTIVE_POWER = "reactive_power" """Reactive power. - Unit of measurement: `var` + Unit of measurement: `var`, `kvar` """ SIGNAL_STRENGTH = "signal_strength" @@ -596,7 +596,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth), SensorDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux), SensorDeviceClass.PRESSURE: set(UnitOfPressure), - SensorDeviceClass.REACTIVE_POWER: {UnitOfReactivePower.VOLT_AMPERE_REACTIVE}, + SensorDeviceClass.REACTIVE_POWER: set(UnitOfReactivePower), SensorDeviceClass.SIGNAL_STRENGTH: { SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, diff --git a/homeassistant/components/sensorpro/manifest.json b/homeassistant/components/sensorpro/manifest.json index ae3229e24c1..ccf042245ea 100644 --- a/homeassistant/components/sensorpro/manifest.json +++ b/homeassistant/components/sensorpro/manifest.json @@ -18,5 +18,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/sensorpro", "iot_class": "local_push", - "requirements": ["sensorpro-ble==0.5.3"] + "requirements": ["sensorpro-ble==0.7.0"] } diff --git a/homeassistant/components/sensorpush/manifest.json b/homeassistant/components/sensorpush/manifest.json index 7729a67d7a1..a7758960b2b 100644 --- a/homeassistant/components/sensorpush/manifest.json +++ b/homeassistant/components/sensorpush/manifest.json @@ -17,5 +17,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/sensorpush", "iot_class": "local_push", - "requirements": ["sensorpush-ble==1.7.1"] + "requirements": ["sensorpush-ble==1.9.0"] } diff --git a/homeassistant/components/sensorpush_cloud/manifest.json b/homeassistant/components/sensorpush_cloud/manifest.json index ad817251fa1..6fd6513ad2d 100644 --- a/homeassistant/components/sensorpush_cloud/manifest.json +++ b/homeassistant/components/sensorpush_cloud/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["sensorpush_api", "sensorpush_ha"], "quality_scale": "bronze", - "requirements": ["sensorpush-api==2.1.1", "sensorpush-ha==1.3.2"] + "requirements": ["sensorpush-api==2.1.2", "sensorpush-ha==1.3.2"] } diff --git a/homeassistant/components/sentry/__init__.py b/homeassistant/components/sentry/__init__.py index 904d493a863..5b89518c616 100644 --- a/homeassistant/components/sentry/__init__.py +++ b/homeassistant/components/sentry/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping import re -from types import MappingProxyType from typing import Any import sentry_sdk @@ -120,7 +120,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def process_before_send( hass: HomeAssistant, - options: MappingProxyType[str, Any], + options: Mapping[str, Any], channel: str, huuid: str, system_info: dict[str, bool | str], diff --git a/homeassistant/components/serial/manifest.json b/homeassistant/components/serial/manifest.json index cfe9196f596..2a5d3c78737 100644 --- a/homeassistant/components/serial/manifest.json +++ b/homeassistant/components/serial/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/serial", "iot_class": "local_polling", - "requirements": ["pyserial-asyncio-fast==0.14"] + "requirements": ["pyserial-asyncio-fast==0.16"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index cdc3b16f95d..6107a6057d1 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["Pillow==11.1.0"] + "requirements": ["Pillow==11.2.1"] } diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index b0f9d6cd2bd..c6fd7942655 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -2,11 +2,8 @@ from __future__ import annotations -from typing import Any - from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_LOCATION from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -14,15 +11,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SeventeenTrackCoordinator -from .const import ( - ATTR_INFO_TEXT, - ATTR_PACKAGES, - ATTR_STATUS, - ATTR_TIMESTAMP, - ATTR_TRACKING_NUMBER, - ATTRIBUTION, - DOMAIN, -) +from .const import ATTRIBUTION, DOMAIN async def async_setup_entry( @@ -81,22 +70,3 @@ class SeventeenTrackSummarySensor(SeventeenTrackSensor): def native_value(self) -> StateType: """Return the state of the sensor.""" return self.coordinator.data.summary[self._status]["quantity"] - - # This has been deprecated in 2024.8, will be removed in 2025.2 - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes.""" - packages = self.coordinator.data.summary[self._status]["packages"] - return { - ATTR_PACKAGES: [ - { - ATTR_TRACKING_NUMBER: package.tracking_number, - ATTR_LOCATION: package.location, - ATTR_STATUS: package.status, - ATTR_TIMESTAMP: package.timestamp, - ATTR_INFO_TEXT: package.info_text, - ATTR_FRIENDLY_NAME: package.friendly_name, - } - for package in packages - ] - } diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index 8b495da56c3..ca064d137b7 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -174,6 +174,7 @@ SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda x: x.alimvoltage, ), SFRBoxSensorEntityDescription[SystemInfo]( @@ -182,6 +183,7 @@ SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda x: _get_temperature(x.temperature), ), ) diff --git a/homeassistant/components/sharkiq/strings.json b/homeassistant/components/sharkiq/strings.json index 3c4c98db38f..33826baaf5b 100644 --- a/homeassistant/components/sharkiq/strings.json +++ b/homeassistant/components/sharkiq/strings.json @@ -3,7 +3,7 @@ "flow_title": "Add Shark IQ account", "step": { "user": { - "description": "Sign into your SharkClean account to control your devices.", + "description": "Sign in to your SharkClean account to control your devices.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index ee28c41f18b..3130acff538 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -56,6 +56,7 @@ from .coordinator import ( ShellyRpcCoordinator, ShellyRpcPollingCoordinator, ) +from .repairs import async_manage_ble_scanner_firmware_unsupported_issue from .utils import ( async_create_issue_unsupported_firmware, get_coap_context, @@ -293,6 +294,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) translation_key="firmware_unsupported", translation_placeholders={"device": entry.title}, ) + runtime_data.rpc_zigbee_enabled = device.zigbee_enabled runtime_data.rpc_supports_scripts = await device.supports_scripts() if runtime_data.rpc_supports_scripts: runtime_data.rpc_script_events = await get_rpc_scripts_event_types( @@ -319,6 +321,10 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) await hass.config_entries.async_forward_entry_setups( entry, runtime_data.platforms ) + async_manage_ble_scanner_firmware_unsupported_issue( + hass, + entry, + ) elif ( sleep_period is None or device_entry is None diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 498f2d3dba9..f8cdb13ba9f 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -7,12 +7,7 @@ from dataclasses import asdict, dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import ( - BLU_TRV_IDENTIFIER, - BLU_TRV_MODEL_NAME, - BLU_TRV_TIMEOUT, - 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 ( @@ -48,7 +43,7 @@ from .const import ( SHTRV_01_TEMPERATURE_SETTINGS, ) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .entity import ShellyRpcEntity +from .entity import ShellyRpcEntity, rpc_call from .utils import ( async_remove_shelly_entity, get_device_entry_gen, @@ -601,17 +596,12 @@ class RpcBluTrvClimate(ShellyRpcEntity, ClimateEntity): return HVACAction.HEATING + @rpc_call 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}, - }, - timeout=BLU_TRV_TIMEOUT, + await self.coordinator.device.blu_trv_set_target_temperature( + self._id, target_temp ) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 6e41df282ef..bde57f6f9bc 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -198,7 +198,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): CONF_GEN: device_info[CONF_GEN], }, ) - errors["base"] = "firmware_not_fully_provisioned" + return self.async_abort(reason="firmware_not_fully_provisioned") return self.async_show_form( step_id="user", data_schema=CONFIG_SCHEMA, errors=errors @@ -238,7 +238,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): CONF_GEN: device_info[CONF_GEN], }, ) - errors["base"] = "firmware_not_fully_provisioned" + return self.async_abort(reason="firmware_not_fully_provisioned") else: user_input = {} @@ -333,21 +333,19 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if not self.device_info[CONF_MODEL]: - errors["base"] = "firmware_not_fully_provisioned" - model = "Shelly" - else: - model = get_model_name(self.info) - if user_input is not None: - return self.async_create_entry( - title=self.device_info["title"], - data={ - CONF_HOST: self.host, - CONF_SLEEP_PERIOD: self.device_info[CONF_SLEEP_PERIOD], - CONF_MODEL: self.device_info[CONF_MODEL], - CONF_GEN: self.device_info[CONF_GEN], - }, - ) - self._set_confirm_only() + return self.async_abort(reason="firmware_not_fully_provisioned") + model = get_model_name(self.info) + if user_input is not None: + return self.async_create_entry( + title=self.device_info["title"], + data={ + CONF_HOST: self.host, + CONF_SLEEP_PERIOD: self.device_info[CONF_SLEEP_PERIOD], + CONF_MODEL: self.device_info[CONF_MODEL], + CONF_GEN: self.device_info[CONF_GEN], + }, + ) + self._set_confirm_only() return self.async_show_form( step_id="confirm_discovery", @@ -477,6 +475,8 @@ class OptionsFlowHandler(OptionsFlow): return self.async_abort(reason="cannot_connect") if not supports_scripts: return self.async_abort(reason="no_scripts_support") + if self.config_entry.runtime_data.rpc_zigbee_enabled: + return self.async_abort(reason="zigbee_enabled") if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index cc3ec564b3f..87fc50a6666 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -227,6 +227,8 @@ class BLEScannerMode(StrEnum): PASSIVE = "passive" +BLE_SCANNER_MIN_FIRMWARE = "1.5.1" + MAX_PUSH_UPDATE_FAILURES = 5 PUSH_UPDATE_ISSUE_ID = "push_update_{unique}" @@ -234,6 +236,8 @@ NOT_CALIBRATED_ISSUE_ID = "not_calibrated_{unique}" FIRMWARE_UNSUPPORTED_ISSUE_ID = "firmware_unsupported_{unique}" +BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID = "ble_scanner_firmware_unsupported_{unique}" + GAS_VALVE_OPEN_STATES = ("opening", "opened") OTA_BEGIN = "ota_begin" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 4a1ea72f38a..f980ba8f914 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -90,6 +90,7 @@ class ShellyEntryData: rpc_poll: ShellyRpcPollingCoordinator | None = None rpc_script_events: dict[int, list[str]] | None = None rpc_supports_scripts: bool | None = None + rpc_zigbee_enabled: bool | None = None type ShellyConfigEntry = ConfigEntry[ShellyEntryData] @@ -717,7 +718,10 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): is updated. """ if not self.sleep_period: - if self.config_entry.runtime_data.rpc_supports_scripts: + if ( + self.config_entry.runtime_data.rpc_supports_scripts + and not self.config_entry.runtime_data.rpc_zigbee_enabled + ): await self._async_connect_ble_scanner() else: await self._async_setup_outbound_websocket() diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 9ed3f47b41a..806f5fea700 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -2,9 +2,10 @@ from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Awaitable, Callable, Coroutine, Mapping from dataclasses import dataclass -from typing import Any, cast +from functools import wraps +from typing import Any, Concatenate, cast from aioshelly.block_device import Block from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError @@ -314,6 +315,41 @@ class RestEntityDescription(EntityDescription): value: Callable[[dict, Any], Any] | None = None +def rpc_call[_T: ShellyRpcEntity, **_P]( + func: Callable[Concatenate[_T, _P], Awaitable[None]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Catch rpc_call exceptions.""" + + @wraps(func) + async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + """Wrap all command methods.""" + try: + await func(self, *args, **kwargs) + except DeviceConnectionError as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err + except RpcCallError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rpc_call_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err + except InvalidAuthError: + await self.coordinator.async_shutdown_device_and_start_reauth() + + return cmd_wrapper + + class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): """Helper class to represent a block entity.""" @@ -363,9 +399,9 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): """Initialize Shelly entity.""" super().__init__(coordinator) self.key = key - self._attr_device_info = { - "connections": {(CONNECTION_NETWORK_MAC, coordinator.mac)} - } + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} + ) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) @@ -392,6 +428,7 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): """Handle device update.""" self.async_write_ha_state() + @rpc_call async def call_rpc( self, method: str, params: Any, timeout: float | None = None ) -> Any: @@ -403,31 +440,9 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): params, timeout, ) - try: - if timeout: - return await self.coordinator.device.call_rpc(method, params, timeout) - return await self.coordinator.device.call_rpc(method, params) - except DeviceConnectionError as err: - self.coordinator.last_update_success = False - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="device_communication_action_error", - translation_placeholders={ - "entity": self.entity_id, - "device": self.coordinator.name, - }, - ) from err - except RpcCallError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="rpc_call_action_error", - translation_placeholders={ - "entity": self.entity_id, - "device": self.coordinator.name, - }, - ) from err - except InvalidAuthError: - await self.coordinator.async_shutdown_device_and_start_reauth() + if timeout: + return await self.coordinator.device.call_rpc(method, params, timeout) + return await self.coordinator.device.call_rpc(method, params) class ShellyBlockAttributeEntity(ShellyBlockEntity, Entity): diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 19ccd1354a7..f60718beca3 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==13.4.1"], + "requirements": ["aioshelly==13.6.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index c629eb4a57a..ab09ad1976a 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final, cast from aioshelly.block_device import Block -from aioshelly.const import BLU_TRV_TIMEOUT, RPC_GENERATIONS +from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.number import ( @@ -34,6 +34,7 @@ from .entity import ( ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rpc, + rpc_call, ) from .utils import ( async_remove_orphaned_entities, @@ -59,13 +60,14 @@ class RpcNumberDescription(RpcEntityDescription, NumberEntityDescription): step_fn: Callable[[dict], float] | None = None mode_fn: Callable[[dict], NumberMode] | None = None method: str - method_params_fn: Callable[[int, float], dict] class RpcNumber(ShellyRpcAttributeEntity, NumberEntity): """Represent a RPC number entity.""" entity_description: RpcNumberDescription + attribute_value: float | None + _id: int | None def __init__( self, @@ -93,20 +95,17 @@ class RpcNumber(ShellyRpcAttributeEntity, NumberEntity): @property def native_value(self) -> float | None: """Return value of number.""" - if TYPE_CHECKING: - assert isinstance(self.attribute_value, float | None) - return self.attribute_value + @rpc_call async def async_set_native_value(self, value: float) -> None: """Change the value.""" - if TYPE_CHECKING: - assert isinstance(self._id, int) + method = getattr(self.coordinator.device, self.entity_description.method) - await self.call_rpc( - self.entity_description.method, - self.entity_description.method_params_fn(self._id, value), - ) + if TYPE_CHECKING: + assert method is not None + + await method(self._id, value) class RpcBluTrvNumber(RpcNumber): @@ -127,17 +126,6 @@ class RpcBluTrvNumber(RpcNumber): connections={(CONNECTION_BLUETOOTH, ble_addr)} ) - async def async_set_native_value(self, value: float) -> None: - """Change the value.""" - if TYPE_CHECKING: - assert isinstance(self._id, int) - - await self.call_rpc( - self.entity_description.method, - self.entity_description.method_params_fn(self._id, value), - timeout=BLU_TRV_TIMEOUT, - ) - class RpcBluTrvExtTempNumber(RpcBluTrvNumber): """Represent a RPC BluTrv External Temperature number.""" @@ -187,12 +175,7 @@ RPC_NUMBERS: Final = { mode=NumberMode.BOX, entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - method="BluTRV.Call", - method_params_fn=lambda idx, value: { - "id": idx, - "method": "Trv.SetExternalTemperature", - "params": {"id": 0, "t_C": value}, - }, + method="blu_trv_set_external_temperature", entity_class=RpcBluTrvExtTempNumber, ), "number": RpcNumberDescription( @@ -209,8 +192,7 @@ RPC_NUMBERS: Final = { unit=lambda config: config["meta"]["ui"]["unit"] if config["meta"]["ui"]["unit"] else None, - method="Number.Set", - method_params_fn=lambda idx, value: {"id": idx, "value": value}, + method="number_set", ), "valve_position": RpcNumberDescription( key="blutrv", @@ -222,12 +204,7 @@ RPC_NUMBERS: Final = { native_step=1, mode=NumberMode.SLIDER, native_unit_of_measurement=PERCENTAGE, - method="BluTRV.Call", - method_params_fn=lambda idx, value: { - "id": idx, - "method": "Trv.SetPosition", - "params": {"id": 0, "pos": int(value)}, - }, + method="blu_trv_set_valve_position", removal_condition=lambda config, _status, key: config[key].get("enable", True) is True, entity_class=RpcBluTrvNumber, diff --git a/homeassistant/components/shelly/quality_scale.yaml b/homeassistant/components/shelly/quality_scale.yaml new file mode 100644 index 00000000000..8fec824bcc1 --- /dev/null +++ b/homeassistant/components/shelly/quality_scale.yaml @@ -0,0 +1,70 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not register services. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration does not register services. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: todo + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration does not register 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: todo + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: The integration connects to a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: done + repair-issues: done + stale-devices: + status: todo + comment: BLU TRV needs to be removed when un-paired + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/shelly/repairs.py b/homeassistant/components/shelly/repairs.py new file mode 100644 index 00000000000..c39f619fc6c --- /dev/null +++ b/homeassistant/components/shelly/repairs.py @@ -0,0 +1,127 @@ +"""Repairs flow for Shelly.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from aioshelly.const import MODEL_OUT_PLUG_S_G3, MODEL_PLUG_S_G3 +from aioshelly.exceptions import DeviceConnectionError, RpcCallError +from aioshelly.rpc_device import RpcDevice +from awesomeversion import AwesomeVersion +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +from .const import ( + BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID, + BLE_SCANNER_MIN_FIRMWARE, + CONF_BLE_SCANNER_MODE, + DOMAIN, + BLEScannerMode, +) +from .coordinator import ShellyConfigEntry + + +@callback +def async_manage_ble_scanner_firmware_unsupported_issue( + hass: HomeAssistant, + entry: ShellyConfigEntry, +) -> None: + """Manage the BLE scanner firmware unsupported issue.""" + issue_id = BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=entry.unique_id) + + if TYPE_CHECKING: + assert entry.runtime_data.rpc is not None + + device = entry.runtime_data.rpc.device + supports_scripts = entry.runtime_data.rpc_supports_scripts + + if supports_scripts and device.model not in (MODEL_PLUG_S_G3, MODEL_OUT_PLUG_S_G3): + firmware = AwesomeVersion(device.shelly["ver"]) + if ( + firmware < BLE_SCANNER_MIN_FIRMWARE + and entry.options.get(CONF_BLE_SCANNER_MODE) == BLEScannerMode.ACTIVE + ): + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="ble_scanner_firmware_unsupported", + translation_placeholders={ + "device_name": device.name, + "ip_address": device.ip_address, + "firmware": firmware, + }, + data={"entry_id": entry.entry_id}, + ) + return + + ir.async_delete_issue(hass, DOMAIN, issue_id) + + +class BleScannerFirmwareUpdateFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, device: RpcDevice) -> None: + """Initialize.""" + self._device = device + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + return await self.async_step_update_firmware() + + issue_registry = ir.async_get(self.hass) + description_placeholders = None + if issue := issue_registry.async_get_issue(self.handler, self.issue_id): + description_placeholders = issue.translation_placeholders + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders=description_placeholders, + ) + + async def async_step_update_firmware( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if not self._device.status["sys"]["available_updates"]: + return self.async_abort(reason="update_not_available") + try: + await self._device.trigger_ota_update() + except (DeviceConnectionError, RpcCallError): + return self.async_abort(reason="cannot_connect") + + return self.async_create_entry(title="", data={}) + + +async def async_create_fix_flow( + hass: HomeAssistant, issue_id: str, data: dict[str, str] | None +) -> RepairsFlow: + """Create flow.""" + if TYPE_CHECKING: + assert isinstance(data, dict) + + entry_id = data["entry_id"] + entry = hass.config_entries.async_get_entry(entry_id) + + if TYPE_CHECKING: + assert entry is not None + + device = entry.runtime_data.rpc.device + return BleScannerFirmwareUpdateFlow(device) diff --git a/homeassistant/components/shelly/select.py b/homeassistant/components/shelly/select.py index 1fb3dfb3447..98d374b496d 100644 --- a/homeassistant/components/shelly/select.py +++ b/homeassistant/components/shelly/select.py @@ -20,6 +20,7 @@ from .entity import ( RpcEntityDescription, ShellyRpcAttributeEntity, async_setup_entry_rpc, + rpc_call, ) from .utils import ( async_remove_orphaned_entities, @@ -75,6 +76,7 @@ class RpcSelect(ShellyRpcAttributeEntity, SelectEntity): """Represent a RPC select entity.""" entity_description: RpcSelectDescription + _id: int def __init__( self, @@ -96,8 +98,9 @@ class RpcSelect(ShellyRpcAttributeEntity, SelectEntity): return self.option_map[self.attribute_value] + @rpc_call async def async_select_option(self, option: str) -> None: """Change the value.""" - await self.call_rpc( - "Enum.Set", {"id": self._id, "value": self.reversed_option_map[option]} + await self.coordinator.device.enum_set( + self._id, self.reversed_option_map[option] ) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 8c8c1b94c48..bc6f44a971b 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -50,21 +50,21 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "custom_port_not_supported": "Gen1 device does not support custom port.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_host": "[%key:common::config_flow::error::invalid_host%]", - "unknown": "[%key:common::config_flow::error::unknown%]", - "firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support", - "custom_port_not_supported": "Gen1 device does not support custom port.", - "mac_address_mismatch": "The MAC address of the device does not match the one in the configuration, please reboot the device and try again." + "mac_address_mismatch": "The MAC address of the device does not match the one in the configuration, please reboot the device and try again.", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used.", + "firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support", + "ipv6_not_supported": "IPv6 is not supported.", + "mac_address_mismatch": "[%key:component::shelly::config::error::mac_address_mismatch%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used.", - "ipv6_not_supported": "IPv6 is not supported.", - "mac_address_mismatch": "[%key:component::shelly::config::error::mac_address_mismatch%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "device_automation": { @@ -104,7 +104,8 @@ }, "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "no_scripts_support": "Device does not support scripts and cannot be used as a Bluetooth scanner." + "no_scripts_support": "Device does not support scripts and cannot be used as a Bluetooth scanner.", + "zigbee_enabled": "Device with Zigbee enabled cannot be used as a Bluetooth scanner. Please disable it to use the device as a Bluetooth scanner." } }, "selector": { @@ -175,16 +176,16 @@ "operation": { "state": { "warmup": "Warm-up", - "normal": "Normal", - "fault": "Fault" + "normal": "[%key:common::state::normal%]", + "fault": "[%key:common::state::fault%]" }, "state_attributes": { "self_test": { "state": { - "not_completed": "Not completed", - "completed": "Completed", - "running": "Running", - "pending": "Pending" + "not_completed": "[%key:component::shelly::entity::sensor::self_test::state::not_completed%]", + "completed": "[%key:component::shelly::entity::sensor::self_test::state::completed%]", + "running": "[%key:component::shelly::entity::sensor::self_test::state::running%]", + "pending": "[%key:component::shelly::entity::sensor::self_test::state::pending%]" } } } @@ -211,10 +212,10 @@ "state": { "checking": "Checking", "closed": "[%key:common::state::closed%]", - "closing": "Closing", + "closing": "[%key:common::state::closing%]", "failure": "Failure", "opened": "Opened", - "opening": "Opening" + "opening": "[%key:common::state::opening%]" } } } @@ -261,6 +262,21 @@ } }, "issues": { + "ble_scanner_firmware_unsupported": { + "title": "{device_name} is running unsupported firmware", + "fix_flow": { + "step": { + "confirm": { + "title": "{device_name} is running unsupported firmware", + "description": "Your Shelly device {device_name} with IP address {ip_address} is running firmware {firmware} and acts as BLE scanner with active mode. This firmware version is not supported for BLE scanner active mode.\n\nSelect **Submit** button to start the OTA update to the latest stable firmware version." + } + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "update_not_available": "Device does not offer firmware update. Check internet connectivity (gateway, DNS, time) and restart the device." + } + } + }, "device_not_calibrated": { "title": "Shelly device {device_name} is not calibrated", "description": "Shelly device {device_name} with IP address {ip_address} requires calibration. To calibrate the device, it must be rebooted after proper installation on the valve. You can reboot the device in its web panel, go to 'Settings' > 'Device Reboot'." diff --git a/homeassistant/components/shelly/text.py b/homeassistant/components/shelly/text.py index f64d1252b7e..811467f9e43 100644 --- a/homeassistant/components/shelly/text.py +++ b/homeassistant/components/shelly/text.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Final +from typing import Final from aioshelly.const import RPC_GENERATIONS @@ -20,6 +20,7 @@ from .entity import ( RpcEntityDescription, ShellyRpcAttributeEntity, async_setup_entry_rpc, + rpc_call, ) from .utils import ( async_remove_orphaned_entities, @@ -75,15 +76,15 @@ class RpcText(ShellyRpcAttributeEntity, TextEntity): """Represent a RPC text entity.""" entity_description: RpcTextDescription + attribute_value: str | None + _id: int @property def native_value(self) -> str | None: """Return value of sensor.""" - if TYPE_CHECKING: - assert isinstance(self.attribute_value, str | None) - return self.attribute_value + @rpc_call async def async_set_value(self, value: str) -> None: """Change the value.""" - await self.call_rpc("Text.Set", {"id": self._id, "value": value}) + await self.coordinator.device.text_set(self._id, value) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 9284afdd567..0c8048d34e4 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -2,10 +2,9 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from datetime import datetime, timedelta from ipaddress import IPv4Address, IPv6Address, ip_address -from types import MappingProxyType from typing import TYPE_CHECKING, Any, cast from aiohttp.web import Request, WebSocketResponse @@ -546,7 +545,7 @@ def is_rpc_wifi_stations_disabled( return True -def get_http_port(data: MappingProxyType[str, Any]) -> int: +def get_http_port(data: Mapping[str, Any]) -> int: """Get port from config entry data.""" return cast(int, data.get(CONF_PORT, DEFAULT_HTTP_PORT)) diff --git a/homeassistant/components/siemens/__init__.py b/homeassistant/components/siemens/__init__.py new file mode 100644 index 00000000000..314b7c63da9 --- /dev/null +++ b/homeassistant/components/siemens/__init__.py @@ -0,0 +1 @@ +"""Siemens virtual integration.""" diff --git a/homeassistant/components/siemens/manifest.json b/homeassistant/components/siemens/manifest.json new file mode 100644 index 00000000000..e53aca0895f --- /dev/null +++ b/homeassistant/components/siemens/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "siemens", + "name": "Siemens", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index e1226fd344d..cee768b6ad0 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["simplehound"], "quality_scale": "legacy", - "requirements": ["Pillow==11.1.0", "simplehound==0.3"] + "requirements": ["Pillow==11.2.1", "simplehound==0.3"] } diff --git a/homeassistant/components/sky_hub/manifest.json b/homeassistant/components/sky_hub/manifest.json index 1030da4d0ff..b3c61aad2db 100644 --- a/homeassistant/components/sky_hub/manifest.json +++ b/homeassistant/components/sky_hub/manifest.json @@ -1,7 +1,7 @@ { "domain": "sky_hub", "name": "Sky Hub", - "codeowners": ["@rogerselwyn"], + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/sky_hub", "iot_class": "local_polling", "loggers": ["pyskyqhub"], diff --git a/homeassistant/components/slack/__init__.py b/homeassistant/components/slack/__init__.py index aa67739016d..899b46ee7e8 100644 --- a/homeassistant/components/slack/__init__.py +++ b/homeassistant/components/slack/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from aiohttp.client_exceptions import ClientError -from slack.errors import SlackApiError +from slack_sdk.errors import SlackApiError from slack_sdk.web.async_client import AsyncWebClient from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/slack/config_flow.py b/homeassistant/components/slack/config_flow.py index fcdc2e8b362..551e9832b2b 100644 --- a/homeassistant/components/slack/config_flow.py +++ b/homeassistant/components/slack/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from slack.errors import SlackApiError +from slack_sdk.errors import SlackApiError from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient import voluptuous as vol diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index 16dd212301a..4c7f52e581f 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -10,7 +10,7 @@ from urllib.parse import urlparse from aiohttp import BasicAuth from aiohttp.client_exceptions import ClientError -from slack.errors import SlackApiError +from slack_sdk.errors import SlackApiError from slack_sdk.web.async_client import AsyncWebClient import voluptuous as vol diff --git a/homeassistant/components/sleepiq/strings.json b/homeassistant/components/sleepiq/strings.json index bdafbfb6c77..634202d6da8 100644 --- a/homeassistant/components/sleepiq/strings.json +++ b/homeassistant/components/sleepiq/strings.json @@ -28,10 +28,10 @@ "select": { "foot_warmer_temp": { "state": { - "off": "Off", - "low": "Low", - "medium": "Medium", - "high": "High" + "off": "[%key:common::state::off%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } } diff --git a/homeassistant/components/sma/__init__.py b/homeassistant/components/sma/__init__.py index 6aae74922e4..27fa54e46dd 100644 --- a/homeassistant/components/sma/__init__.py +++ b/homeassistant/components/sma/__init__.py @@ -10,7 +10,9 @@ import pysma from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_CONNECTIONS, CONF_HOST, + CONF_MAC, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_SSL, @@ -19,6 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -75,6 +78,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: serial_number=sma_device_info["serial"], ) + # Add the MAC address to connections, if it comes via DHCP + if CONF_MAC in entry.data: + device_info[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, entry.data[CONF_MAC]) + } + # Define the coordinator async def async_update_data(): """Update the used SMA sensors.""" diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index 3f5eb635989..3210d904b6b 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -7,26 +7,43 @@ from typing import Any import pysma import voluptuous as vol +from yarl import URL from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_NAME, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv 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_GROUP, DOMAIN, GROUPS _LOGGER = logging.getLogger(__name__) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: +async def validate_input( + hass: HomeAssistant, + user_input: dict[str, Any], + data: dict[str, Any] | None = None, +) -> dict[str, Any]: """Validate the user input allows us to connect.""" - session = async_get_clientsession(hass, verify_ssl=data[CONF_VERIFY_SSL]) + session = async_get_clientsession(hass, verify_ssl=user_input[CONF_VERIFY_SSL]) - protocol = "https" if data[CONF_SSL] else "http" - url = f"{protocol}://{data[CONF_HOST]}" + protocol = "https" if user_input[CONF_SSL] else "http" + host = data[CONF_HOST] if data is not None else user_input[CONF_HOST] + url = URL.build(scheme=protocol, host=host) - sma = pysma.SMA(session, url, data[CONF_PASSWORD], group=data[CONF_GROUP]) + sma = pysma.SMA( + session, str(url), user_input[CONF_PASSWORD], group=user_input[CONF_GROUP] + ) # new_session raises SmaAuthenticationException on failure await sma.new_session() @@ -51,34 +68,53 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): CONF_GROUP: GROUPS[0], CONF_PASSWORD: vol.UNDEFINED, } + self._discovery_data: dict[str, Any] = {} + + async def _handle_user_input( + self, user_input: dict[str, Any], discovery: bool = False + ) -> tuple[dict[str, str], dict[str, str]]: + """Handle the user input.""" + errors: dict[str, str] = {} + device_info: dict[str, str] = {} + + if not discovery: + self._data[CONF_HOST] = user_input[CONF_HOST] + + self._data[CONF_SSL] = user_input[CONF_SSL] + self._data[CONF_VERIFY_SSL] = user_input[CONF_VERIFY_SSL] + self._data[CONF_GROUP] = user_input[CONF_GROUP] + self._data[CONF_PASSWORD] = user_input[CONF_PASSWORD] + + try: + device_info = await validate_input( + self.hass, user_input=user_input, data=self._data + ) + except pysma.exceptions.SmaConnectionException: + errors["base"] = "cannot_connect" + except pysma.exceptions.SmaAuthenticationException: + errors["base"] = "invalid_auth" + except pysma.exceptions.SmaReadException: + errors["base"] = "cannot_retrieve_device_info" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return errors, device_info async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """First step in config flow.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: - self._data[CONF_HOST] = user_input[CONF_HOST] - self._data[CONF_SSL] = user_input[CONF_SSL] - self._data[CONF_VERIFY_SSL] = user_input[CONF_VERIFY_SSL] - self._data[CONF_GROUP] = user_input[CONF_GROUP] - self._data[CONF_PASSWORD] = user_input[CONF_PASSWORD] - - try: - device_info = await validate_input(self.hass, user_input) - except pysma.exceptions.SmaConnectionException: - errors["base"] = "cannot_connect" - except pysma.exceptions.SmaAuthenticationException: - errors["base"] = "invalid_auth" - except pysma.exceptions.SmaReadException: - errors["base"] = "cannot_retrieve_device_info" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + errors, device_info = await self._handle_user_input(user_input=user_input) if not errors: - await self.async_set_unique_id(str(device_info["serial"])) + await self.async_set_unique_id( + str(device_info["serial"]), raise_on_progress=False + ) self._abort_if_unique_id_configured(updates=self._data) + return self.async_create_entry( title=self._data[CONF_HOST], data=self._data ) @@ -100,3 +136,50 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + 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 + self._data[CONF_HOST] = discovery_info.ip + self._data[CONF_MAC] = format_mac(self._discovery_data[CONF_MAC]) + + await self.async_set_unique_id(discovery_info.hostname.replace("SMA", "")) + self._abort_if_unique_id_configured() + + 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: + errors, device_info = await self._handle_user_input( + user_input=user_input, discovery=True + ) + + if not errors: + return self.async_create_entry( + title=self._data[CONF_HOST], data=self._data + ) + + return self.async_show_form( + step_id="discovery_confirm", + data_schema=vol.Schema( + { + vol.Optional(CONF_SSL, default=self._data[CONF_SSL]): cv.boolean, + vol.Optional( + CONF_VERIFY_SSL, default=self._data[CONF_VERIFY_SSL] + ): cv.boolean, + vol.Optional(CONF_GROUP, default=self._data[CONF_GROUP]): vol.In( + GROUPS + ), + vol.Required(CONF_PASSWORD): cv.string, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 8024aad82d6..bb3f5318280 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -3,6 +3,13 @@ "name": "SMA Solar", "codeowners": ["@kellerza", "@rklomp", "@erwindouna"], "config_flow": true, + "dhcp": [ + { + "hostname": "sma*", + "macaddress": "0015BB*" + }, + { "registered_devices": true } + ], "documentation": "https://www.home-assistant.io/integrations/sma", "iot_class": "local_polling", "loggers": ["pysma"], diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index c8ca1a819e0..cec71f91750 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -24,6 +24,7 @@ from pysmartthings import ( SmartThingsSinkError, Status, ) +from pysmartthings.models import HealthStatus from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -79,6 +80,7 @@ class FullDevice: device: Device status: dict[str, ComponentStatus] + online: bool type SmartThingsConfigEntry = ConfigEntry[SmartThingsData] @@ -192,7 +194,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) devices = await client.get_devices() for device in devices: status = process_status(await client.get_device_status(device.device_id)) - device_status[device.device_id] = FullDevice(device=device, status=status) + online = await client.get_device_health(device.device_id) + device_status[device.device_id] = FullDevice( + device=device, status=status, online=online.state == HealthStatus.ONLINE + ) except SmartThingsAuthenticationFailedError as err: raise ConfigEntryAuthFailed from err diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 5544297a4c6..b25838ad8c9 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -10,8 +10,10 @@ from pysmartthings import ( Command, ComponentStatus, DeviceEvent, + DeviceHealthEvent, SmartThings, ) +from pysmartthings.models import HealthStatus from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -48,6 +50,7 @@ class SmartThingsEntity(Entity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device.device.device_id)}, ) + self._attr_available = device.online async def async_added_to_hass(self) -> None: """Subscribe to updates.""" @@ -61,8 +64,17 @@ class SmartThingsEntity(Entity): self._update_handler, ) ) + self.async_on_remove( + self.client.add_device_availability_event_listener( + self.device.device.device_id, self._availability_handler + ) + ) self._update_attr() + def _availability_handler(self, event: DeviceHealthEvent) -> None: + self._attr_available = event.status != HealthStatus.OFFLINE + self.async_write_ha_state() + def _update_handler(self, event: DeviceEvent) -> None: self._internal_state[event.capability][event.attribute].value = event.value self._internal_state[event.capability][event.attribute].data = event.data diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index 214a9953a5a..3125bd65548 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -40,6 +40,12 @@ "pause": "mdi:pause", "stop": "mdi:stop" } + }, + "detergent_amount": { + "default": "mdi:car-coolant-level" + }, + "flexible_detergent_amount": { + "default": "mdi:car-coolant-level" } }, "switch": { diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index c682b5402c4..043bdea71e2 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.0.5"] + "requirements": ["pysmartthings==3.2.1"] } diff --git a/homeassistant/components/smartthings/number.py b/homeassistant/components/smartthings/number.py index 2f2ac7903f2..0a9b5dcb03f 100644 --- a/homeassistant/components/smartthings/number.py +++ b/homeassistant/components/smartthings/number.py @@ -5,6 +5,7 @@ from __future__ import annotations from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.number import NumberEntity, NumberMode +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -33,6 +34,7 @@ class SmartThingsWasherRinseCyclesNumberEntity(SmartThingsEntity, NumberEntity): _attr_translation_key = "washer_rinse_cycles" _attr_native_step = 1.0 _attr_mode = NumberMode.BOX + _attr_entity_category = EntityCategory.CONFIG def __init__(self, client: SmartThings, device: FullDevice) -> None: """Initialize the instance.""" diff --git a/homeassistant/components/smartthings/quality_scale.yaml b/homeassistant/components/smartthings/quality_scale.yaml index be8a9039617..384ce2ea0b6 100644 --- a/homeassistant/components/smartthings/quality_scale.yaml +++ b/homeassistant/components/smartthings/quality_scale.yaml @@ -37,7 +37,7 @@ rules: docs-installation-parameters: status: exempt comment: No parameters needed during installation - entity-unavailable: todo + entity-unavailable: done integration-owner: done log-when-unavailable: todo parallel-updates: todo diff --git a/homeassistant/components/smartthings/select.py b/homeassistant/components/smartthings/select.py index f0a483b1329..16051cb08f1 100644 --- a/homeassistant/components/smartthings/select.py +++ b/homeassistant/components/smartthings/select.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from pysmartthings import Attribute, Capability, Command, SmartThings 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 AddConfigEntryEntitiesCallback @@ -21,10 +22,11 @@ class SmartThingsSelectDescription(SelectEntityDescription): """Class describing SmartThings select entities.""" key: Capability - requires_remote_control_status: bool + requires_remote_control_status: bool = False options_attribute: Attribute status_attribute: Attribute command: Command + default_options: list[str] | None = None CAPABILITIES_TO_SELECT: dict[Capability | str, SmartThingsSelectDescription] = { @@ -45,6 +47,7 @@ CAPABILITIES_TO_SELECT: dict[Capability | str, SmartThingsSelectDescription] = { options_attribute=Attribute.SUPPORTED_MACHINE_STATES, status_attribute=Attribute.MACHINE_STATE, command=Command.SET_MACHINE_STATE, + default_options=["run", "pause", "stop"], ), Capability.WASHER_OPERATING_STATE: SmartThingsSelectDescription( key=Capability.WASHER_OPERATING_STATE, @@ -54,6 +57,23 @@ CAPABILITIES_TO_SELECT: dict[Capability | str, SmartThingsSelectDescription] = { options_attribute=Attribute.SUPPORTED_MACHINE_STATES, status_attribute=Attribute.MACHINE_STATE, command=Command.SET_MACHINE_STATE, + default_options=["run", "pause", "stop"], + ), + Capability.SAMSUNG_CE_AUTO_DISPENSE_DETERGENT: SmartThingsSelectDescription( + key=Capability.SAMSUNG_CE_AUTO_DISPENSE_DETERGENT, + translation_key="detergent_amount", + options_attribute=Attribute.SUPPORTED_AMOUNT, + status_attribute=Attribute.AMOUNT, + command=Command.SET_AMOUNT, + entity_category=EntityCategory.CONFIG, + ), + Capability.SAMSUNG_CE_FLEXIBLE_AUTO_DISPENSE_DETERGENT: SmartThingsSelectDescription( + key=Capability.SAMSUNG_CE_FLEXIBLE_AUTO_DISPENSE_DETERGENT, + translation_key="flexible_detergent_amount", + options_attribute=Attribute.SUPPORTED_AMOUNT, + status_attribute=Attribute.AMOUNT, + command=Command.SET_AMOUNT, + entity_category=EntityCategory.CONFIG, ), } @@ -97,8 +117,12 @@ class SmartThingsSelectEntity(SmartThingsEntity, SelectEntity): @property def options(self) -> list[str]: """Return the list of options.""" - return self.get_attribute_value( - self.entity_description.key, self.entity_description.options_attribute + return ( + self.get_attribute_value( + self.entity_description.key, self.entity_description.options_attribute + ) + or self.entity_description.default_options + or [] ) @property diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index d5a465b8ccc..2d6451fa279 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -631,7 +631,7 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key="powerEnergy_meter", translation_key="power_energy", - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["powerEnergy"] / 1000, @@ -990,6 +990,18 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, + Capability.SAMSUNG_CE_WATER_CONSUMPTION_REPORT: { + Attribute.WATER_CONSUMPTION: [ + SmartThingsSensorEntityDescription( + key=Attribute.WATER_CONSUMPTION, + translation_key="water_consumption", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.LITERS, + value_fn=lambda value: value["cumulativeAmount"] / 1000, + ) + ] + }, } diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 90792d21660..81f4d34c8bb 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -112,7 +112,27 @@ "state": { "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", "pause": "[%key:common::state::paused%]", - "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + "stop": "[%key:common::state::stopped%]" + } + }, + "detergent_amount": { + "name": "Detergent dispense amount", + "state": { + "none": "[%key:common::state::off%]", + "less": "Less", + "standard": "Standard", + "extra": "Extra", + "custom": "Custom" + } + }, + "flexible_detergent_amount": { + "name": "Flexible compartment dispense amount", + "state": { + "none": "[%key:common::state::off%]", + "less": "[%key:component::smartthings::entity::select::detergent_amount::state::less%]", + "standard": "[%key:component::smartthings::entity::select::detergent_amount::state::standard%]", + "extra": "[%key:component::smartthings::entity::select::detergent_amount::state::extra%]", + "custom": "[%key:component::smartthings::entity::select::detergent_amount::state::custom%]" } } }, @@ -157,7 +177,7 @@ "state": { "pause": "[%key:common::state::paused%]", "run": "Running", - "stop": "Stopped" + "stop": "[%key:common::state::stopped%]" } }, "dishwasher_job_state": { @@ -186,7 +206,7 @@ "state": { "pause": "[%key:common::state::paused%]", "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", - "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + "stop": "[%key:common::state::stopped%]" } }, "dryer_job_state": { @@ -357,11 +377,11 @@ "robot_cleaner_cleaning_mode": { "name": "Cleaning mode", "state": { - "auto": "Auto", + "stop": "[%key:common::action::stop%]", + "auto": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]", "part": "Partial", "repeat": "Repeat", - "manual": "Manual", - "stop": "[%key:common::action::stop%]", "map": "Map" } }, @@ -444,7 +464,7 @@ "state": { "pause": "[%key:common::state::paused%]", "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", - "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + "stop": "[%key:common::state::stopped%]" } }, "washer_job_state": { @@ -467,6 +487,9 @@ "wrinkle_prevent": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wrinkle_prevent%]", "freeze_protection": "Freeze protection" } + }, + "water_consumption": { + "name": "Water consumption" } }, "switch": { @@ -478,6 +501,9 @@ }, "ice_maker": { "name": "Ice maker" + }, + "sabbath_mode": { + "name": "Sabbath mode" } } }, diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index ff53082ac7c..56e67ad2a13 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -12,6 +12,7 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -69,6 +70,7 @@ CAPABILITY_TO_COMMAND_SWITCHES: dict[ translation_key="wrinkle_prevent", status_attribute=Attribute.DRYER_WRINKLE_PREVENT, command=Command.SET_DRYER_WRINKLE_PREVENT, + entity_category=EntityCategory.CONFIG, ) } CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescription] = { @@ -76,6 +78,7 @@ CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescriptio key=Capability.SAMSUNG_CE_WASHER_BUBBLE_SOAK, translation_key="bubble_soak", status_attribute=Attribute.STATUS, + entity_category=EntityCategory.CONFIG, ), Capability.SWITCH: SmartThingsSwitchEntityDescription( key=Capability.SWITCH, @@ -84,6 +87,11 @@ CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescriptio "icemaker": "ice_maker", }, ), + Capability.SAMSUNG_CE_SABBATH_MODE: SmartThingsSwitchEntityDescription( + key=Capability.SAMSUNG_CE_SABBATH_MODE, + translation_key="sabbath_mode", + status_attribute=Attribute.STATUS, + ), } diff --git a/homeassistant/components/smarttub/strings.json b/homeassistant/components/smarttub/strings.json index 79fa7a4820f..8391aaa4d47 100644 --- a/homeassistant/components/smarttub/strings.json +++ b/homeassistant/components/smarttub/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Login", - "description": "Enter your SmartTub email address and password to login", + "description": "Enter your SmartTub email address and password to log in", "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index aab8c6ab3c7..1803f501dc7 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -1,34 +1,10 @@ """Support to control a Salda Smarty XP/XV ventilation unit.""" -import ipaddress -import logging +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_NAME, Platform -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import config_validation as cv, issue_registry as ir -from homeassistant.helpers.typing import ConfigType - -from .const import DOMAIN from .coordinator import SmartyConfigEntry, SmartyCoordinator -_LOGGER = logging.getLogger(__name__) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_HOST): vol.All(ipaddress.ip_address, cv.string), - vol.Optional(CONF_NAME, default="Smarty"): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -38,54 +14,6 @@ PLATFORMS = [ ] -async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: - """Create a smarty system.""" - if config := hass_config.get(DOMAIN): - hass.async_create_task(_async_import(hass, config)) - return True - - -async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: - """Set up the smarty environment.""" - - 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: - ir.async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{result['reason']}", - breaks_in_ha_version="2025.5.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": "Smarty", - }, - ) - return - - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Smarty", - }, - ) - - async def async_setup_entry(hass: HomeAssistant, entry: SmartyConfigEntry) -> bool: """Set up the Smarty environment from a config entry.""" diff --git a/homeassistant/components/smarty/config_flow.py b/homeassistant/components/smarty/config_flow.py index a7f0bdd4123..5abae121cd7 100644 --- a/homeassistant/components/smarty/config_flow.py +++ b/homeassistant/components/smarty/config_flow.py @@ -7,7 +7,7 @@ from pysmarty2 import Smarty import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_HOST from .const import DOMAIN @@ -50,17 +50,3 @@ class SmartyConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required(CONF_HOST): str}), errors=errors, ) - - async def async_step_import( - self, import_config: dict[str, Any] - ) -> ConfigFlowResult: - """Handle a flow initialized by import.""" - error = await self.hass.async_add_executor_job( - self._test_connection, import_config[CONF_HOST] - ) - if not error: - return self.async_create_entry( - title=import_config[CONF_NAME], - data={CONF_HOST: import_config[CONF_HOST]}, - ) - return self.async_abort(reason=error) diff --git a/homeassistant/components/smarty/entity.py b/homeassistant/components/smarty/entity.py index d26b56d489f..f6533000f45 100644 --- a/homeassistant/components/smarty/entity.py +++ b/homeassistant/components/smarty/entity.py @@ -3,7 +3,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN +from .const import DOMAIN from .coordinator import SmartyCoordinator diff --git a/homeassistant/components/smarty/strings.json b/homeassistant/components/smarty/strings.json index 341a300a26e..d9852ab40d3 100644 --- a/homeassistant/components/smarty/strings.json +++ b/homeassistant/components/smarty/strings.json @@ -20,20 +20,6 @@ "unknown": "[%key:common::config_flow::error::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_cannot_connect": { - "title": "YAML import failed due to a connection error", - "description": "Configuring {integration_title} using YAML is being removed but there was a connect 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." - } - }, "entity": { "binary_sensor": { "alarm": { diff --git a/homeassistant/components/smlight/binary_sensor.py b/homeassistant/components/smlight/binary_sensor.py index ce3457ae81b..aaba15e19f2 100644 --- a/homeassistant/components/smlight/binary_sensor.py +++ b/homeassistant/components/smlight/binary_sensor.py @@ -22,6 +22,7 @@ from .const import SCAN_INTERNET_INTERVAL from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 0 SCAN_INTERVAL = SCAN_INTERNET_INTERVAL diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py index 5caf43b7cba..f834392ea13 100644 --- a/homeassistant/components/smlight/button.py +++ b/homeassistant/components/smlight/button.py @@ -23,6 +23,8 @@ from .const import DOMAIN from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 5a118e7de15..8a8dcd74b8f 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -111,7 +111,11 @@ class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): raise ConfigEntryAuthFailed from err except SmlightConnectionError as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect_device", + translation_placeholders={"error": str(err)}, + ) from err @abstractmethod async def _internal_update_data(self) -> _DataT: diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index e9025203b8c..b2a03a737fc 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -11,6 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", + "quality_scale": "silver", "requirements": ["pysmlight==0.2.4"], "zeroconf": [ { diff --git a/homeassistant/components/smlight/quality_scale.yaml b/homeassistant/components/smlight/quality_scale.yaml new file mode 100644 index 00000000000..5c6d7364704 --- /dev/null +++ b/homeassistant/components/smlight/quality_scale.yaml @@ -0,0 +1,85 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + 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: + status: done + comment: | + Entities subscribe to SSE events from pysmlight library. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: done + comment: Handled implicitly within coordinator + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not provide an option flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: done + comment: Handled by coordinator + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: done + 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: | + Device type integration. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: done + stale-devices: + status: exempt + comment: | + Device type integration. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py index 2f57843b5eb..f045d009a00 100644 --- a/homeassistant/components/smlight/sensor.py +++ b/homeassistant/components/smlight/sensor.py @@ -25,6 +25,8 @@ from .const import UPTIME_DEVIATION from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class SmSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index ca52f6fea38..4abc6349d1e 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -15,6 +15,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Username for the device's web login.", + "password": "Password for the device's web login." } }, "reauth_confirm": { @@ -23,6 +27,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::smlight::config::step::auth::data_description::username%]", + "password": "[%key:component::smlight::config::step::auth::data_description::password%]" } }, "confirm_discovery": { @@ -137,6 +145,14 @@ } } }, + "exceptions": { + "firmware_update_failed": { + "message": "Firmware update failed for {device_name}." + }, + "cannot_connect_device": { + "message": "An error occurred while connecting to the SMLIGHT device: {error}." + } + }, "issues": { "unsupported_firmware": { "title": "SLZB core firmware update required", diff --git a/homeassistant/components/smlight/switch.py b/homeassistant/components/smlight/switch.py index 09d2714956c..5cd187c009c 100644 --- a/homeassistant/components/smlight/switch.py +++ b/homeassistant/components/smlight/switch.py @@ -22,6 +22,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py index 3143f2f4290..d7aed0ecb4d 100644 --- a/homeassistant/components/smlight/update.py +++ b/homeassistant/components/smlight/update.py @@ -22,10 +22,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import LOGGER +from .const import DOMAIN, LOGGER from .coordinator import SmConfigEntry, SmFirmwareUpdateCoordinator, SmFwData from .entity import SmEntity +PARALLEL_UPDATES = 1 + def zigbee_latest_version(data: SmFwData, idx: int) -> Firmware | None: """Get the latest Zigbee firmware version.""" @@ -208,7 +210,13 @@ class SmUpdateEntity(SmEntity, UpdateEntity): def _update_failed(self, event: MessageEvent) -> None: self._update_done() self.coordinator.in_progress = False - raise HomeAssistantError(f"Update failed for {self.name}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="firmware_update_failed", + translation_placeholders={ + "device_name": str(self.name), + }, + ) async def async_install( self, version: str | None, backup: bool, **kwargs: Any diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index e86b22690a4..b0f484f0cb1 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -11,6 +11,9 @@ import logging import os from pathlib import Path import smtplib +import socket +import ssl +from typing import Any import voluptuous as vol @@ -38,7 +41,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from homeassistant.util.ssl import client_context +from homeassistant.util.ssl import create_client_context from .const import ( ATTR_HTML, @@ -86,6 +89,7 @@ def get_service( ) -> MailNotificationService | None: """Get the mail notification service.""" setup_reload_service(hass, DOMAIN, PLATFORMS) + ssl_context = create_client_context() if config[CONF_VERIFY_SSL] else None mail_service = MailNotificationService( config[CONF_SERVER], config[CONF_PORT], @@ -98,6 +102,7 @@ def get_service( config.get(CONF_SENDER_NAME), config[CONF_DEBUG], config[CONF_VERIFY_SSL], + ssl_context, ) if mail_service.connection_is_valid(): @@ -111,18 +116,19 @@ class MailNotificationService(BaseNotificationService): def __init__( self, - server, - port, - timeout, - sender, - encryption, - username, - password, - recipients, - sender_name, - debug, - verify_ssl, - ): + server: str, + port: int, + timeout: int, + sender: str, + encryption: str, + username: str | None, + password: str | None, + recipients: list[str], + sender_name: str | None, + debug: bool, + verify_ssl: bool, + ssl_context: ssl.SSLContext | None, + ) -> None: """Initialize the SMTP service.""" self._server = server self._port = port @@ -136,34 +142,35 @@ class MailNotificationService(BaseNotificationService): self.debug = debug self._verify_ssl = verify_ssl self.tries = 2 + self._ssl_context = ssl_context - def connect(self): + def connect(self) -> smtplib.SMTP_SSL | smtplib.SMTP: """Connect/authenticate to SMTP Server.""" - ssl_context = client_context() if self._verify_ssl else None + mail: smtplib.SMTP_SSL | smtplib.SMTP if self.encryption == "tls": mail = smtplib.SMTP_SSL( self._server, self._port, timeout=self._timeout, - context=ssl_context, + context=self._ssl_context, ) else: mail = smtplib.SMTP(self._server, self._port, timeout=self._timeout) mail.set_debuglevel(self.debug) mail.ehlo_or_helo_if_needed() if self.encryption == "starttls": - mail.starttls(context=ssl_context) + mail.starttls(context=self._ssl_context) mail.ehlo() if self.username and self.password: mail.login(self.username, self.password) return mail - def connection_is_valid(self): + def connection_is_valid(self) -> bool: """Check for valid config, verify connectivity.""" server = None try: server = self.connect() - except (smtplib.socket.gaierror, ConnectionRefusedError): + except (socket.gaierror, ConnectionRefusedError): _LOGGER.exception( ( "SMTP server not found or refused connection (%s:%s). Please check" @@ -185,7 +192,7 @@ class MailNotificationService(BaseNotificationService): return True - def send_message(self, message="", **kwargs): + def send_message(self, message: str, **kwargs: Any) -> None: """Build and send a message to a user. Will send plain text normally, with pictures as attachments if images config is @@ -193,6 +200,7 @@ class MailNotificationService(BaseNotificationService): """ subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + msg: MIMEMultipart | MIMEText if data := kwargs.get(ATTR_DATA): if ATTR_HTML in data: msg = _build_html_msg( @@ -210,20 +218,24 @@ class MailNotificationService(BaseNotificationService): msg["Subject"] = subject - if not (recipients := kwargs.get(ATTR_TARGET)): + if targets := kwargs.get(ATTR_TARGET): + recipients: list[str] = targets # ensured by NOTIFY_SERVICE_SCHEMA + else: recipients = self.recipients - msg["To"] = recipients if isinstance(recipients, str) else ",".join(recipients) + msg["To"] = ",".join(recipients) + if self._sender_name: msg["From"] = f"{self._sender_name} <{self._sender}>" else: msg["From"] = self._sender + msg["X-Mailer"] = "Home Assistant" msg["Date"] = email.utils.format_datetime(dt_util.now()) msg["Message-Id"] = email.utils.make_msgid() return self._send_email(msg, recipients) - def _send_email(self, msg, recipients): + def _send_email(self, msg: MIMEMultipart | MIMEText, recipients: list[str]) -> None: """Send the message.""" mail = self.connect() for _ in range(self.tries): @@ -243,13 +255,15 @@ class MailNotificationService(BaseNotificationService): mail.quit() -def _build_text_msg(message): +def _build_text_msg(message: str) -> MIMEText: """Build plaintext email.""" _LOGGER.debug("Building plain text email") return MIMEText(message) -def _attach_file(hass, atch_name, content_id=""): +def _attach_file( + hass: HomeAssistant, atch_name: str, content_id: str | None = None +) -> MIMEImage | MIMEApplication | None: """Create a message attachment. If MIMEImage is successful and content_id is passed (HTML), add images in-line. @@ -268,7 +282,7 @@ def _attach_file(hass, atch_name, content_id=""): translation_key="remote_path_not_allowed", translation_placeholders={ "allow_list": allow_list, - "file_path": file_path, + "file_path": str(file_path), "file_name": file_name, "url": url, }, @@ -279,6 +293,7 @@ def _attach_file(hass, atch_name, content_id=""): _LOGGER.warning("Attachment %s not found. Skipping", atch_name) return None + attachment: MIMEImage | MIMEApplication try: attachment = MIMEImage(file_bytes) except TypeError: @@ -302,7 +317,9 @@ def _attach_file(hass, atch_name, content_id=""): return attachment -def _build_multipart_msg(hass, message, images): +def _build_multipart_msg( + hass: HomeAssistant, message: str, images: list[str] +) -> MIMEMultipart: """Build Multipart message with images as attachments.""" _LOGGER.debug("Building multipart email with image attachme_build_html_msgnt(s)") msg = MIMEMultipart() @@ -317,7 +334,9 @@ def _build_multipart_msg(hass, message, images): return msg -def _build_html_msg(hass, text, html, images): +def _build_html_msg( + hass: HomeAssistant, text: str, html: str, images: list[str] +) -> MIMEMultipart: """Build Multipart message with in-line images and rich HTML (UTF-8).""" _LOGGER.debug("Building HTML rich email") msg = MIMEMultipart("related") diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 0baecd68ec4..bd50e2050e0 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -45,6 +45,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_PICTURE, TEMPLATE_SENSOR_BASE_SCHEMA, ManualTriggerSensorEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -94,7 +95,9 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( vol.Optional(CONF_DEFAULT_VALUE): cv.string, vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.In(SNMP_VERSIONS), vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_AUTH_KEY): cv.string, @@ -173,7 +176,7 @@ async def async_setup_platform( continue trigger_entity_config[key] = config[key] - value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) + value_template: ValueTemplate | None = config.get(CONF_VALUE_TEMPLATE) data = SnmpData(request_args, baseoid, accept_errors, default_value) async_add_entities([SnmpSensor(hass, data, trigger_entity_config, value_template)]) @@ -189,7 +192,7 @@ class SnmpSensor(ManualTriggerSensorEntity): hass: HomeAssistant, data: SnmpData, config: ConfigType, - value_template: Template | None, + value_template: ValueTemplate | None, ) -> None: """Initialize the sensor.""" super().__init__(hass, config) @@ -206,17 +209,16 @@ class SnmpSensor(ManualTriggerSensorEntity): """Get the latest data and updates the states.""" await self.data.async_update() - raw_value = self.data.value - + variables = self._template_variables_with_value(self.data.value) if (value := self.data.value) is None: value = STATE_UNKNOWN elif self._value_template is not None: - value = self._value_template.async_render_with_possible_json_value( - value, STATE_UNKNOWN + value = self._value_template.async_render_as_value_template( + self.entity_id, variables, STATE_UNKNOWN ) self._attr_native_value = value - self._process_manual_data(raw_value) + self._process_manual_data(variables) class SnmpData: diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json index 72b0342c7f4..1c86c066c7f 100644 --- a/homeassistant/components/snoo/strings.json +++ b/homeassistant/components/snoo/strings.json @@ -71,7 +71,7 @@ "level2": "Level 2", "level3": "Level 3", "level4": "Level 4", - "stop": "Stopped", + "stop": "[%key:common::state::stopped%]", "pretimeout": "Pre-timeout", "timeout": "Timeout" } @@ -89,7 +89,7 @@ "level2": "[%key:component::snoo::entity::sensor::state::state::level2%]", "level3": "[%key:component::snoo::entity::sensor::state::state::level3%]", "level4": "[%key:component::snoo::entity::sensor::state::state::level4%]", - "stop": "[%key:component::snoo::entity::sensor::state::state::stop%]" + "stop": "[%key:common::state::stopped%]" } } }, diff --git a/homeassistant/components/soma/manifest.json b/homeassistant/components/soma/manifest.json index 5884e5f53c4..ed0c5ff6240 100644 --- a/homeassistant/components/soma/manifest.json +++ b/homeassistant/components/soma/manifest.json @@ -1,7 +1,7 @@ { "domain": "soma", "name": "Soma Connect", - "codeowners": ["@ratsept", "@sebfortier2288"], + "codeowners": ["@ratsept"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/soma", "iot_class": "local_polling", diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index 1b9e8502209..e3e6c699d03 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -28,6 +28,7 @@ from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType @@ -55,7 +56,9 @@ QUERY_SCHEMA = vol.Schema( vol.Required(CONF_NAME): cv.template, vol.Required(CONF_QUERY): vol.All(cv.string, validate_sql_select), vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DB_URL): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 37b5dc2b647..e6a45390120 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.39", "sqlparse==0.5.0"] + "requirements": ["SQLAlchemy==2.0.40", "sqlparse==0.5.0"] } diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index a7b488dd521..b86a33db7ab 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -45,6 +45,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, ManualTriggerSensorEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -79,7 +80,7 @@ async def async_setup_platform( name: Template = conf[CONF_NAME] query_str: str = conf[CONF_QUERY] - value_template: Template | None = conf.get(CONF_VALUE_TEMPLATE) + value_template: ValueTemplate | None = conf.get(CONF_VALUE_TEMPLATE) column_name: str = conf[CONF_COLUMN_NAME] unique_id: str | None = conf.get(CONF_UNIQUE_ID) db_url: str = resolve_db_url(hass, conf.get(CONF_DB_URL)) @@ -116,10 +117,10 @@ async def async_setup_entry( template: str | None = entry.options.get(CONF_VALUE_TEMPLATE) column_name: str = entry.options[CONF_COLUMN_NAME] - value_template: Template | None = None + value_template: ValueTemplate | None = None if template is not None: try: - value_template = Template(template, hass) + value_template = ValueTemplate(template, hass) value_template.ensure_valid() except TemplateError: value_template = None @@ -179,7 +180,7 @@ async def async_setup_sensor( trigger_entity_config: ConfigType, query_str: str, column_name: str, - value_template: Template | None, + value_template: ValueTemplate | None, unique_id: str | None, db_url: str, yaml: bool, @@ -316,7 +317,7 @@ class SQLSensor(ManualTriggerSensorEntity): sessmaker: scoped_session, query: str, column: str, - value_template: Template | None, + value_template: ValueTemplate | None, yaml: bool, use_database_executor: bool, ) -> None: @@ -359,14 +360,14 @@ class SQLSensor(ManualTriggerSensorEntity): async def async_update(self) -> None: """Retrieve sensor data from the query using the right executor.""" if self._use_database_executor: - data = await get_instance(self.hass).async_add_executor_job(self._update) + await get_instance(self.hass).async_add_executor_job(self._update) else: - data = await self.hass.async_add_executor_job(self._update) - self._process_manual_data(data) + await self.hass.async_add_executor_job(self._update) - def _update(self) -> Any: + def _update(self) -> None: """Retrieve sensor data from the query.""" data = None + extra_state_attributes = {} self._attr_extra_state_attributes = {} sess: scoped_session = self.sessionmaker() try: @@ -379,7 +380,7 @@ class SQLSensor(ManualTriggerSensorEntity): ) sess.rollback() sess.close() - return None + return for res in result.mappings(): _LOGGER.debug("Query %s result in %s", self._query, res.items()) @@ -391,15 +392,19 @@ class SQLSensor(ManualTriggerSensorEntity): value = value.isoformat() elif isinstance(value, (bytes, bytearray)): value = f"0x{value.hex()}" + extra_state_attributes[key] = value self._attr_extra_state_attributes[key] = value if data is not None and isinstance(data, (bytes, bytearray)): data = f"0x{data.hex()}" if data is not None and self._template is not None: - self._attr_native_value = ( - self._template.async_render_with_possible_json_value(data, None) - ) + variables = self._template_variables_with_value(data) + if self._render_availability_template(variables): + self._attr_native_value = self._template.async_render_as_value_template( + self.entity_id, variables, None + ) + self._process_manual_data(variables) else: self._attr_native_value = data @@ -407,4 +412,3 @@ class SQLSensor(ManualTriggerSensorEntity): _LOGGER.warning("%s returned no results", self._query) sess.close() - return data diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index eadd706fcd8..3f4af99fffd 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -22,34 +22,34 @@ from homeassistant.helpers.network import is_internal_request from .const import UNPLAYABLE_TYPES LIBRARY = [ - "Favorites", - "Artists", - "Albums", - "Tracks", - "Playlists", - "Genres", - "New Music", - "Album Artists", - "Apps", - "Radios", + "favorites", + "artists", + "albums", + "tracks", + "playlists", + "genres", + "new music", + "album artists", + "apps", + "radios", ] MEDIA_TYPE_TO_SQUEEZEBOX: dict[str | MediaType, str] = { - "Favorites": "favorites", - "Artists": "artists", - "Albums": "albums", - "Tracks": "titles", - "Playlists": "playlists", - "Genres": "genres", - "New Music": "new music", - "Album Artists": "album artists", + "favorites": "favorites", + "artists": "artists", + "albums": "albums", + "tracks": "titles", + "playlists": "playlists", + "genres": "genres", + "new music": "new music", + "album artists": "album artists", MediaType.ALBUM: "album", MediaType.ARTIST: "artist", MediaType.TRACK: "title", MediaType.PLAYLIST: "playlist", MediaType.GENRE: "genre", - "Apps": "apps", - "Radios": "radios", + MediaType.APPS: "apps", + "radios": "radios", } SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = { @@ -58,22 +58,20 @@ SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = { MediaType.TRACK: "track_id", MediaType.PLAYLIST: "playlist_id", MediaType.GENRE: "genre_id", - "Favorites": "item_id", + "favorites": "item_id", MediaType.APPS: "item_id", } CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | str]] = { - "Favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, - "Apps": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, - "Radios": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, - "App": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, - "Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, - "Albums": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, - "Tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, - "Playlists": {"item": MediaClass.DIRECTORY, "children": MediaClass.PLAYLIST}, - "Genres": {"item": MediaClass.DIRECTORY, "children": MediaClass.GENRE}, - "New Music": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, - "Album Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, + "favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, + "radios": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, + "artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, + "albums": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, + "tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, + "playlists": {"item": MediaClass.DIRECTORY, "children": MediaClass.PLAYLIST}, + "genres": {"item": MediaClass.DIRECTORY, "children": MediaClass.GENRE}, + "new music": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, + "album artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, MediaType.ALBUM: {"item": MediaClass.ALBUM, "children": MediaClass.TRACK}, MediaType.ARTIST: {"item": MediaClass.ARTIST, "children": MediaClass.ALBUM}, MediaType.TRACK: {"item": MediaClass.TRACK, "children": ""}, @@ -91,17 +89,15 @@ CONTENT_TYPE_TO_CHILD_TYPE: dict[ MediaType.PLAYLIST: MediaType.PLAYLIST, MediaType.ARTIST: MediaType.ALBUM, MediaType.GENRE: MediaType.ARTIST, - "Artists": MediaType.ARTIST, - "Albums": MediaType.ALBUM, - "Tracks": MediaType.TRACK, - "Playlists": MediaType.PLAYLIST, - "Genres": MediaType.GENRE, - "Favorites": None, # can only be determined after inspecting the item - "Apps": MediaClass.APP, - "Radios": MediaClass.APP, - "App": None, # can only be determined after inspecting the item - "New Music": MediaType.ALBUM, - "Album Artists": MediaType.ARTIST, + "artists": MediaType.ARTIST, + "albums": MediaType.ALBUM, + "tracks": MediaType.TRACK, + "playlists": MediaType.PLAYLIST, + "genres": MediaType.GENRE, + "favorites": None, # can only be determined after inspecting the item + "radios": MediaClass.APP, + "new music": MediaType.ALBUM, + "album artists": MediaType.ARTIST, MediaType.APPS: MediaType.APP, MediaType.APP: MediaType.TRACK, } @@ -173,7 +169,7 @@ def _build_response_known_app( def _build_response_favorites(item: dict[str, Any]) -> BrowseMedia: - """Build item for Favorites.""" + """Build item for favorites.""" if "album_id" in item: return BrowseMedia( media_content_id=str(item["album_id"]), @@ -183,21 +179,21 @@ def _build_response_favorites(item: dict[str, Any]) -> BrowseMedia: can_expand=True, can_play=True, ) - if item["hasitems"] and not item["isaudio"]: + if item.get("hasitems") and not item.get("isaudio"): return BrowseMedia( media_content_id=item["id"], title=item["title"], - media_content_type="Favorites", - media_class=CONTENT_TYPE_MEDIA_CLASS["Favorites"]["item"], + media_content_type="favorites", + media_class=CONTENT_TYPE_MEDIA_CLASS["favorites"]["item"], can_expand=True, can_play=False, ) return BrowseMedia( media_content_id=item["id"], title=item["title"], - media_content_type="Favorites", + media_content_type="favorites", media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK]["item"], - can_expand=item["hasitems"], + can_expand=bool(item.get("hasitems")), can_play=bool(item["isaudio"] and item.get("url")), ) @@ -220,7 +216,7 @@ def _get_item_thumbnail( item_type, item["id"], artwork_track_id ) - elif search_type in ["Apps", "Radios"]: + elif search_type in ["apps", "radios"]: item_thumbnail = player.generate_image_url(item["icon"]) if item_thumbnail is None: item_thumbnail = item.get("image_url") # will not be proxied by HA @@ -265,10 +261,10 @@ async def build_item_response( for item in result["items"]: # Force the item id to a string in case it's numeric from some lms item["id"] = str(item.get("id", "")) - if search_type == "Favorites": + if search_type == "favorites": child_media = _build_response_favorites(item) - elif search_type in ["Apps", "Radios"]: + elif search_type in ["apps", "radios"]: # item["cmd"] contains the name of the command to use with the cli for the app # add the command to the dictionaries if item["title"] == "Search" or item.get("type") in UNPLAYABLE_TYPES: @@ -364,11 +360,11 @@ async def library_payload( assert media_class["children"] is not None library_info["children"].append( BrowseMedia( - title=item, + title=item.title(), media_class=media_class["children"], media_content_id=item, media_content_type=item, - can_play=item not in ["Favorites", "Apps", "Radios"], + can_play=item not in ["favorites", "apps", "radios"], can_expand=True, ) ) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 40662477745..6e99099ccb1 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -446,6 +446,9 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): """Send the play_media command to the media player.""" index = None + if media_type: + media_type = media_type.lower() + enqueue: MediaPlayerEnqueue | None = kwargs.get(ATTR_MEDIA_ENQUEUE) if enqueue == MediaPlayerEnqueue.ADD: @@ -617,6 +620,9 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): media_content_id, ) + if media_content_type: + media_content_type = media_content_type.lower() + if media_content_type in [None, "library"]: return await library_payload(self.hass, self._player, self._browse_data) diff --git a/homeassistant/components/srp_energy/strings.json b/homeassistant/components/srp_energy/strings.json index 5fa97b00b57..dfe2ea32888 100644 --- a/homeassistant/components/srp_energy/strings.json +++ b/homeassistant/components/srp_energy/strings.json @@ -18,7 +18,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "unknown": "Unexpected error" + "unknown": "[%key:common::config_flow::error::unknown%]" } }, "entity": { diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index c5fb349ddbb..28ea59c0adc 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -2,59 +2,18 @@ from __future__ import annotations -import asyncio -from collections.abc import Callable, Coroutine, Mapping -from datetime import timedelta -from enum import Enum +from collections.abc import Callable, Coroutine from functools import partial -from ipaddress import IPv4Address, IPv6Address -import logging -import socket -from time import time -from typing import TYPE_CHECKING, Any -from urllib.parse import urljoin -import xml.etree.ElementTree as ET +from typing import Any -from async_upnp_client.aiohttp import AiohttpSessionRequester -from async_upnp_client.const import ( - AddressTupleVXType, - DeviceIcon, - DeviceInfo, - DeviceOrServiceType, - SsdpSource, -) -from async_upnp_client.description_cache import DescriptionCache -from async_upnp_client.server import UpnpServer, UpnpServerDevice, UpnpServerService -from async_upnp_client.ssdp import ( - SSDP_PORT, - determine_source_target, - fix_ipv6_address_scope_id, - is_ipv4_address, -) -from async_upnp_client.ssdp_listener import SsdpDevice, SsdpDeviceTracker, SsdpListener -from async_upnp_client.utils import CaseInsensitiveDict - -from homeassistant import config_entries -from homeassistant.components import network -from homeassistant.const import ( - EVENT_HOMEASSISTANT_STARTED, - EVENT_HOMEASSISTANT_STOP, - MATCH_ALL, - __version__ as current_version, -) -from homeassistant.core import Event, HassJob, HomeAssistant, callback as core_callback -from homeassistant.helpers import config_validation as cv, discovery_flow -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import HassJob, HomeAssistant +from homeassistant.helpers import config_validation as cv 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, @@ -73,20 +32,19 @@ from homeassistant.helpers.service_info.ssdp import ( 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 -from homeassistant.util.async_ import create_eager_task from homeassistant.util.logging import catch_log_exception -DOMAIN = "ssdp" -SSDP_SCANNER = "scanner" -UPNP_SERVER = "server" -UPNP_SERVER_MIN_PORT = 40000 -UPNP_SERVER_MAX_PORT = 40100 -SCAN_INTERVAL = timedelta(minutes=10) - -IPV4_BROADCAST = IPv4Address("255.255.255.255") +from . import websocket_api +from .const import DOMAIN, SSDP_SCANNER, UPNP_SERVER +from .scanner import ( + IntegrationMatchers, + Scanner, + SsdpChange, + SsdpHassJobCallback, # noqa: F401 +) +from .server import Server # Attributes for accessing info from SSDP response ATTR_SSDP_LOCATION = "ssdp_location" @@ -177,17 +135,6 @@ _DEPRECATED_ATTR_UPNP_PRESENTATION_URL = DeprecatedConstant( # 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, -] - -_LOGGER = logging.getLogger(__name__) - - CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) _DEPRECATED_SsdpServiceInfo = DeprecatedConstant( @@ -197,20 +144,6 @@ _DEPRECATED_SsdpServiceInfo = DeprecatedConstant( ) -SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE") -type SsdpHassJobCallback = HassJob[ - [_SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None -] - -SSDP_SOURCE_SSDP_CHANGE_MAPPING: Mapping[SsdpSource, SsdpChange] = { - SsdpSource.SEARCH_ALIVE: SsdpChange.ALIVE, - SsdpSource.SEARCH_CHANGED: SsdpChange.ALIVE, - SsdpSource.ADVERTISEMENT_ALIVE: SsdpChange.ALIVE, - SsdpSource.ADVERTISEMENT_BYEBYE: SsdpChange.BYEBYE, - SsdpSource.ADVERTISEMENT_UPDATE: SsdpChange.UPDATE, -} - - def _format_err(name: str, *args: Any) -> str: """Format error message.""" return f"Exception in SSDP callback {name}: {args}" @@ -266,17 +199,6 @@ async def async_get_discovery_info_by_udn( return await scanner.async_get_discovery_info_by_udn(udn) -async def async_build_source_set(hass: HomeAssistant) -> set[IPv4Address | IPv6Address]: - """Build the list of ssdp sources.""" - return { - source_ip - 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) - } - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the SSDP integration.""" @@ -292,676 +214,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await scanner.async_start() await server.async_start() + websocket_api.async_setup(hass) return True -@core_callback -def _async_process_callbacks( - hass: HomeAssistant, - callbacks: list[SsdpHassJobCallback], - discovery_info: _SsdpServiceInfo, - ssdp_change: SsdpChange, -) -> None: - for callback in callbacks: - try: - hass.async_run_hass_job( - callback, discovery_info, ssdp_change, background=True - ) - except Exception: - _LOGGER.exception("Failed to callback info: %s", discovery_info) - - -@core_callback -def _async_headers_match( - headers: CaseInsensitiveDict, lower_match_dict: dict[str, str] -) -> bool: - for header, val in lower_match_dict.items(): - if val == MATCH_ALL: - if header not in headers: - return False - elif headers.get_lower(header) != val: - return False - return True - - -class IntegrationMatchers: - """Optimized integration matching.""" - - def __init__(self) -> None: - """Init optimized integration matching.""" - self._match_by_key: ( - dict[str, dict[str, list[tuple[str, dict[str, str]]]]] | None - ) = None - - @core_callback - def async_setup( - self, integration_matchers: dict[str, list[dict[str, str]]] - ) -> None: - """Build matchers by key. - - Here we convert the primary match keys into their own - dicts so we can do lookups of the primary match - key to find the match dict. - """ - self._match_by_key = {} - for key in PRIMARY_MATCH_KEYS: - matchers_by_key = self._match_by_key[key] = {} - for domain, matchers in integration_matchers.items(): - for matcher in matchers: - if match_value := matcher.get(key): - matchers_by_key.setdefault(match_value, []).append( - (domain, matcher) - ) - - @core_callback - def async_matching_domains(self, info_with_desc: CaseInsensitiveDict) -> set[str]: - """Find domains matching the passed CaseInsensitiveDict.""" - assert self._match_by_key is not None - return { - domain - for key, matchers_by_key in self._match_by_key.items() - if (match_value := info_with_desc.get(key)) - for domain, matcher in matchers_by_key.get(match_value, ()) - if info_with_desc.items() >= matcher.items() - } - - -class Scanner: - """Class to manage SSDP searching and SSDP advertisements.""" - - def __init__( - self, hass: HomeAssistant, integration_matchers: IntegrationMatchers - ) -> None: - """Initialize class.""" - self.hass = hass - self._cancel_scan: Callable[[], None] | None = None - self._ssdp_listeners: list[SsdpListener] = [] - self._device_tracker = SsdpDeviceTracker() - self._callbacks: list[tuple[SsdpHassJobCallback, dict[str, str]]] = [] - self._description_cache: DescriptionCache | None = None - self.integration_matchers = integration_matchers - - @property - def _ssdp_devices(self) -> list[SsdpDevice]: - """Get all seen devices.""" - return list(self._device_tracker.devices.values()) - - async def async_register_callback( - self, callback: SsdpHassJobCallback, match_dict: dict[str, str] | None = None - ) -> Callable[[], None]: - """Register a callback.""" - if match_dict is None: - lower_match_dict = {} - else: - lower_match_dict = {k.lower(): v for k, v in match_dict.items()} - - # Make sure any entries that happened - # before the callback was registered are fired - for ssdp_device in self._ssdp_devices: - for headers in ssdp_device.all_combined_headers.values(): - if _async_headers_match(headers, lower_match_dict): - _async_process_callbacks( - self.hass, - [callback], - await self._async_headers_to_discovery_info( - ssdp_device, headers - ), - SsdpChange.ALIVE, - ) - - callback_entry = (callback, lower_match_dict) - self._callbacks.append(callback_entry) - - @core_callback - def _async_remove_callback() -> None: - self._callbacks.remove(callback_entry) - - return _async_remove_callback - - async def async_stop(self, *_: Any) -> None: - """Stop the scanner.""" - assert self._cancel_scan is not None - self._cancel_scan() - - await self._async_stop_ssdp_listeners() - - async def _async_stop_ssdp_listeners(self) -> None: - """Stop the SSDP listeners.""" - await asyncio.gather( - *( - create_eager_task(listener.async_stop()) - for listener in self._ssdp_listeners - ), - return_exceptions=True, - ) - - async def async_scan(self, *_: Any) -> None: - """Scan for new entries using ssdp listeners.""" - await self.async_scan_multicast() - await self.async_scan_broadcast() - - async def async_scan_multicast(self, *_: Any) -> None: - """Scan for new entries using multicase target.""" - for ssdp_listener in self._ssdp_listeners: - await ssdp_listener.async_search() - - async def async_scan_broadcast(self, *_: Any) -> None: - """Scan for new entries using broadcast target.""" - # Some sonos devices only seem to respond if we send to the broadcast - # address. This matches pysonos' behavior - # https://github.com/amelchio/pysonos/blob/d4329b4abb657d106394ae69357805269708c996/pysonos/discovery.py#L120 - for listener in self._ssdp_listeners: - if is_ipv4_address(listener.source): - await listener.async_search((str(IPV4_BROADCAST), SSDP_PORT)) - - async def async_start(self) -> None: - """Start the scanners.""" - session = async_get_clientsession(self.hass, verify_ssl=False) - requester = AiohttpSessionRequester(session, True, 10) - self._description_cache = DescriptionCache(requester) - - await self._async_start_ssdp_listeners() - - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) - self._cancel_scan = async_track_time_interval( - self.hass, self.async_scan, SCAN_INTERVAL, name="SSDP scanner" - ) - - async_dispatcher_connect( - self.hass, - config_entries.signal_discovered_config_entry_removed(DOMAIN), - self._handle_config_entry_removed, - ) - - # Trigger the initial-scan. - await self.async_scan() - - async def _async_start_ssdp_listeners(self) -> None: - """Start the SSDP Listeners.""" - # Devices are shared between all sources. - for source_ip in await async_build_source_set(self.hass): - source_ip_str = str(source_ip) - if source_ip.version == 6: - source_tuple: AddressTupleVXType = ( - source_ip_str, - 0, - 0, - int(getattr(source_ip, "scope_id")), - ) - else: - source_tuple = (source_ip_str, 0) - source, target = determine_source_target(source_tuple) - source = fix_ipv6_address_scope_id(source) or source - self._ssdp_listeners.append( - SsdpListener( - callback=self._ssdp_listener_callback, - source=source, - target=target, - device_tracker=self._device_tracker, - ) - ) - results = await asyncio.gather( - *( - create_eager_task(listener.async_start()) - for listener in self._ssdp_listeners - ), - return_exceptions=True, - ) - failed_listeners = [] - for idx, result in enumerate(results): - if isinstance(result, Exception): - _LOGGER.debug( - "Failed to setup listener for %s: %s", - self._ssdp_listeners[idx].source, - result, - ) - failed_listeners.append(self._ssdp_listeners[idx]) - for listener in failed_listeners: - self._ssdp_listeners.remove(listener) - - @core_callback - def _async_get_matching_callbacks( - self, - combined_headers: CaseInsensitiveDict, - ) -> list[SsdpHassJobCallback]: - """Return a list of callbacks that match.""" - return [ - callback - for callback, lower_match_dict in self._callbacks - if _async_headers_match(combined_headers, lower_match_dict) - ] - - def _ssdp_listener_callback( - self, - ssdp_device: SsdpDevice, - dst: DeviceOrServiceType, - source: SsdpSource, - ) -> None: - """Handle a device/service change.""" - _LOGGER.debug( - "SSDP: ssdp_device: %s, dst: %s, source: %s", ssdp_device, dst, source - ) - - assert self._description_cache - - location = ssdp_device.location - _, info_desc = self._description_cache.peek_description_dict(location) - if info_desc is None: - # Fetch info desc in separate task and process from there. - self.hass.async_create_background_task( - self._ssdp_listener_process_callback_with_lookup( - ssdp_device, dst, source - ), - name=f"ssdp_info_desc_lookup_{location}", - eager_start=True, - ) - return - - # Info desc known, process directly. - self._ssdp_listener_process_callback(ssdp_device, dst, source, info_desc) - - async def _ssdp_listener_process_callback_with_lookup( - self, - ssdp_device: SsdpDevice, - dst: DeviceOrServiceType, - source: SsdpSource, - ) -> None: - """Handle a device/service change.""" - location = ssdp_device.location - self._ssdp_listener_process_callback( - ssdp_device, - dst, - source, - await self._async_get_description_dict(location), - ) - - def _ssdp_listener_process_callback( - self, - ssdp_device: SsdpDevice, - dst: DeviceOrServiceType, - source: SsdpSource, - info_desc: Mapping[str, Any], - skip_callbacks: bool = False, - ) -> None: - """Handle a device/service change.""" - matching_domains: set[str] = set() - combined_headers = ssdp_device.combined_headers(dst) - callbacks = self._async_get_matching_callbacks(combined_headers) - - # If there are no changes from a search, do not trigger a config flow - if source != SsdpSource.SEARCH_ALIVE: - matching_domains = self.integration_matchers.async_matching_domains( - CaseInsensitiveDict(combined_headers.as_dict(), **info_desc) - ) - - if ( - not callbacks - and not matching_domains - and source != SsdpSource.ADVERTISEMENT_BYEBYE - ): - return - - discovery_info = discovery_info_from_headers_and_description( - ssdp_device, combined_headers, info_desc - ) - discovery_info.x_homeassistant_matching_domains = matching_domains - - if callbacks and not skip_callbacks: - ssdp_change = SSDP_SOURCE_SSDP_CHANGE_MAPPING[source] - _async_process_callbacks(self.hass, callbacks, discovery_info, ssdp_change) - - # Config flows should only be created for alive/update messages from alive devices - if source == SsdpSource.ADVERTISEMENT_BYEBYE: - self._async_dismiss_discoveries(discovery_info) - return - - _LOGGER.debug("Discovery info: %s", discovery_info) - - if not matching_domains: - return # avoid creating DiscoveryKey if there are no matches - - discovery_key = discovery_flow.DiscoveryKey( - domain=DOMAIN, key=ssdp_device.udn, version=1 - ) - for domain in matching_domains: - _LOGGER.debug("Discovered %s at %s", domain, ssdp_device.location) - discovery_flow.async_create_flow( - self.hass, - domain, - {"source": config_entries.SOURCE_SSDP}, - discovery_info, - discovery_key=discovery_key, - ) - - def _async_dismiss_discoveries( - 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, - lambda service_info: bool( - service_info.ssdp_st == byebye_discovery_info.ssdp_st - and service_info.ssdp_location == byebye_discovery_info.ssdp_location - ), - ): - self.hass.config_entries.flow.async_abort(flow["flow_id"]) - - async def _async_get_description_dict( - self, location: str | None - ) -> Mapping[str, str]: - """Get description dict.""" - assert self._description_cache is not None - cache = self._description_cache - - has_description, description = cache.peek_description_dict(location) - if has_description: - return description or {} - - return await cache.async_get_description_dict(location) or {} - - async def _async_headers_to_discovery_info( - self, ssdp_device: SsdpDevice, headers: CaseInsensitiveDict - ) -> _SsdpServiceInfo: - """Combine the headers and description into discovery_info. - - Building this is a bit expensive so we only do it on demand. - """ - location = headers["location"] - info_desc = await self._async_get_description_dict(location) - return discovery_info_from_headers_and_description( - ssdp_device, headers, info_desc - ) - - async def async_get_discovery_info_by_udn_st( - self, udn: str, st: str - ) -> _SsdpServiceInfo | None: - """Return discovery_info for a udn and st.""" - for ssdp_device in self._ssdp_devices: - if ssdp_device.udn == udn: - if headers := ssdp_device.combined_headers(st): - return await self._async_headers_to_discovery_info( - ssdp_device, headers - ) - return None - - 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) - for ssdp_device in self._ssdp_devices - if (headers := ssdp_device.combined_headers(st)) - ] - - 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) - for ssdp_device in self._ssdp_devices - for headers in ssdp_device.all_combined_headers.values() - if ssdp_device.udn == udn - ] - - @core_callback - def _handle_config_entry_removed( - self, - entry: config_entries.ConfigEntry, - ) -> None: - """Handle config entry changes.""" - if TYPE_CHECKING: - assert self._description_cache is not None - cache = self._description_cache - for discovery_key in entry.discovery_keys[DOMAIN]: - if discovery_key.version != 1 or not isinstance(discovery_key.key, str): - continue - udn = discovery_key.key - _LOGGER.debug("Rediscover service %s", udn) - - for ssdp_device in self._ssdp_devices: - if ssdp_device.udn != udn: - continue - for dst in ssdp_device.all_combined_headers: - has_cached_desc, info_desc = cache.peek_description_dict( - ssdp_device.location - ) - if has_cached_desc and info_desc: - self._ssdp_listener_process_callback( - ssdp_device, - dst, - SsdpSource.SEARCH, - info_desc, - True, # Skip integration callbacks - ) - - -def discovery_info_from_headers_and_description( - ssdp_device: SsdpDevice, - combined_headers: CaseInsensitiveDict, - info_desc: Mapping[str, Any], -) -> _SsdpServiceInfo: - """Convert headers and description to discovery_info.""" - ssdp_usn = combined_headers["usn"] - ssdp_st = combined_headers.get_lower("st") - if isinstance(info_desc, CaseInsensitiveDict): - upnp_info = {**info_desc.as_dict()} - else: - upnp_info = {**info_desc} - - # Increase compatibility: depending on the message type, - # either the ST (Search Target, from M-SEARCH messages) - # or NT (Notification Type, from NOTIFY messages) header is mandatory - if not ssdp_st: - ssdp_st = combined_headers["nt"] - - # Ensure UPnP "udn" is set - if _ATTR_UPNP_UDN not in upnp_info: - if udn := _udn_from_usn(ssdp_usn): - upnp_info[_ATTR_UPNP_UDN] = udn - - return _SsdpServiceInfo( - ssdp_usn=ssdp_usn, - ssdp_st=ssdp_st, - ssdp_ext=combined_headers.get_lower("ext"), - ssdp_server=combined_headers.get_lower("server"), - ssdp_location=combined_headers.get_lower("location"), - ssdp_udn=combined_headers.get_lower("_udn"), - ssdp_nt=combined_headers.get_lower("nt"), - ssdp_headers=combined_headers, - upnp=upnp_info, - ssdp_all_locations=set(ssdp_device.locations), - ) - - -def _udn_from_usn(usn: str | None) -> str | None: - """Get the UDN from the USN.""" - if usn is None: - return None - if usn.startswith("uuid:"): - return usn.split("::")[0] - return None - - -class HassUpnpServiceDevice(UpnpServerDevice): - """Hass Device.""" - - DEVICE_DEFINITION = DeviceInfo( - device_type="urn:home-assistant.io:device:HomeAssistant:1", - friendly_name="filled_later_on", - manufacturer="Home Assistant", - manufacturer_url="https://www.home-assistant.io", - model_description=None, - model_name="filled_later_on", - model_number=current_version, - model_url="https://www.home-assistant.io", - serial_number="filled_later_on", - udn="filled_later_on", - upc=None, - presentation_url="https://my.home-assistant.io/", - url="/device.xml", - icons=[ - DeviceIcon( - mimetype="image/png", - width=1024, - height=1024, - depth=24, - url="/static/icons/favicon-1024x1024.png", - ), - DeviceIcon( - mimetype="image/png", - width=512, - height=512, - depth=24, - url="/static/icons/favicon-512x512.png", - ), - DeviceIcon( - mimetype="image/png", - width=384, - height=384, - depth=24, - url="/static/icons/favicon-384x384.png", - ), - DeviceIcon( - mimetype="image/png", - width=192, - height=192, - depth=24, - url="/static/icons/favicon-192x192.png", - ), - ], - xml=ET.Element("server_device"), - ) - EMBEDDED_DEVICES: list[type[UpnpServerDevice]] = [] - SERVICES: list[type[UpnpServerService]] = [] - - -async def _async_find_next_available_port(source: AddressTupleVXType) -> int: - """Get a free TCP port.""" - family = socket.AF_INET if is_ipv4_address(source) else socket.AF_INET6 - test_socket = socket.socket(family, socket.SOCK_STREAM) - test_socket.setblocking(False) - test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - - for port in range(UPNP_SERVER_MIN_PORT, UPNP_SERVER_MAX_PORT): - addr = (source[0],) + (port,) + source[2:] - try: - test_socket.bind(addr) - except OSError: - if port == UPNP_SERVER_MAX_PORT - 1: - raise - else: - return port - - raise RuntimeError("unreachable") - - -class Server: - """Class to be visible via SSDP searching and advertisements.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize class.""" - self.hass = hass - self._upnp_servers: list[UpnpServer] = [] - - async def async_start(self) -> None: - """Start the server.""" - bus = self.hass.bus - bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) - bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, - self._async_start_upnp_servers, - ) - - async def _async_get_instance_udn(self) -> str: - """Get Unique Device Name for this instance.""" - instance_id = await async_get_instance_id(self.hass) - return f"uuid:{instance_id[0:8]}-{instance_id[8:12]}-{instance_id[12:16]}-{instance_id[16:20]}-{instance_id[20:32]}".upper() - - async def _async_start_upnp_servers(self, event: Event) -> None: - """Start the UPnP/SSDP servers.""" - # Update UDN with our instance UDN. - udn = await self._async_get_instance_udn() - system_info = await async_get_system_info(self.hass) - model_name = system_info["installation_type"] - try: - presentation_url = get_url(self.hass, allow_ip=True, prefer_external=False) - except NoURLAvailableError: - _LOGGER.warning( - "Could not set up UPnP/SSDP server, as a presentation URL could" - " not be determined; Please configure your internal URL" - " in the Home Assistant general configuration" - ) - return - - serial_number = await async_get_instance_id(self.hass) - HassUpnpServiceDevice.DEVICE_DEFINITION = ( - HassUpnpServiceDevice.DEVICE_DEFINITION._replace( - udn=udn, - friendly_name=f"{self.hass.config.location_name} (Home Assistant)", - model_name=model_name, - presentation_url=presentation_url, - serial_number=serial_number, - ) - ) - - # Update icon URLs. - for index, icon in enumerate(HassUpnpServiceDevice.DEVICE_DEFINITION.icons): - new_url = urljoin(presentation_url, icon.url) - HassUpnpServiceDevice.DEVICE_DEFINITION.icons[index] = icon._replace( - url=new_url - ) - - # Start a server on all source IPs. - boot_id = int(time()) - for source_ip in await async_build_source_set(self.hass): - source_ip_str = str(source_ip) - if source_ip.version == 6: - source_tuple: AddressTupleVXType = ( - source_ip_str, - 0, - 0, - int(getattr(source_ip, "scope_id")), - ) - else: - source_tuple = (source_ip_str, 0) - source, target = determine_source_target(source_tuple) - source = fix_ipv6_address_scope_id(source) or source - http_port = await _async_find_next_available_port(source) - _LOGGER.debug("Binding UPnP HTTP server to: %s:%s", source_ip, http_port) - self._upnp_servers.append( - UpnpServer( - source=source, - target=target, - http_port=http_port, - server_device=HassUpnpServiceDevice, - boot_id=boot_id, - ) - ) - results = await asyncio.gather( - *(upnp_server.async_start() for upnp_server in self._upnp_servers), - return_exceptions=True, - ) - failed_servers = [] - for idx, result in enumerate(results): - if isinstance(result, Exception): - _LOGGER.debug( - "Failed to setup server for %s: %s", - self._upnp_servers[idx].source, - result, - ) - failed_servers.append(self._upnp_servers[idx]) - for server in failed_servers: - self._upnp_servers.remove(server) - - async def async_stop(self, *_: Any) -> None: - """Stop the server.""" - await self._async_stop_upnp_servers() - - 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( diff --git a/homeassistant/components/ssdp/common.py b/homeassistant/components/ssdp/common.py new file mode 100644 index 00000000000..47156b13ce7 --- /dev/null +++ b/homeassistant/components/ssdp/common.py @@ -0,0 +1,19 @@ +"""Common functions for SSDP discovery.""" + +from __future__ import annotations + +from ipaddress import IPv4Address, IPv6Address + +from homeassistant.components import network +from homeassistant.core import HomeAssistant + + +async def async_build_source_set(hass: HomeAssistant) -> set[IPv4Address | IPv6Address]: + """Build the list of ssdp sources.""" + return { + source_ip + 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) + } diff --git a/homeassistant/components/ssdp/const.py b/homeassistant/components/ssdp/const.py new file mode 100644 index 00000000000..ee5f1c240c6 --- /dev/null +++ b/homeassistant/components/ssdp/const.py @@ -0,0 +1,7 @@ +"""Constants for the SSDP integration.""" + +from __future__ import annotations + +DOMAIN = "ssdp" +SSDP_SCANNER = "scanner" +UPNP_SERVER = "server" diff --git a/homeassistant/components/ssdp/scanner.py b/homeassistant/components/ssdp/scanner.py new file mode 100644 index 00000000000..d42c879e76a --- /dev/null +++ b/homeassistant/components/ssdp/scanner.py @@ -0,0 +1,558 @@ +"""The SSDP integration scanner.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine, Mapping +from datetime import timedelta +from enum import Enum +from ipaddress import IPv4Address +import logging +from typing import TYPE_CHECKING, Any + +from async_upnp_client.aiohttp import AiohttpSessionRequester +from async_upnp_client.const import AddressTupleVXType, DeviceOrServiceType, SsdpSource +from async_upnp_client.description_cache import DescriptionCache +from async_upnp_client.ssdp import ( + SSDP_PORT, + determine_source_target, + fix_ipv6_address_scope_id, + is_ipv4_address, +) +from async_upnp_client.ssdp_listener import SsdpDevice, SsdpDeviceTracker, SsdpListener +from async_upnp_client.utils import CaseInsensitiveDict + +from homeassistant import config_entries +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, MATCH_ALL +from homeassistant.core import HassJob, HomeAssistant, callback as core_callback +from homeassistant.helpers import discovery_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.event import async_track_time_interval +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_MANUFACTURER as _ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MANUFACTURER_URL as _ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_UDN as _ATTR_UPNP_UDN, + SsdpServiceInfo as _SsdpServiceInfo, +) +from homeassistant.util.async_ import create_eager_task + +from .common import async_build_source_set +from .const import DOMAIN + +SCAN_INTERVAL = timedelta(minutes=10) + +IPV4_BROADCAST = IPv4Address("255.255.255.255") + + +PRIMARY_MATCH_KEYS = [ + _ATTR_UPNP_MANUFACTURER, + _ATTR_ST, + _ATTR_UPNP_DEVICE_TYPE, + _ATTR_NT, + _ATTR_UPNP_MANUFACTURER_URL, +] + +_LOGGER = logging.getLogger(__name__) + + +SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE") +type SsdpHassJobCallback = HassJob[ + [_SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None +] + +SSDP_SOURCE_SSDP_CHANGE_MAPPING: Mapping[SsdpSource, SsdpChange] = { + SsdpSource.SEARCH_ALIVE: SsdpChange.ALIVE, + SsdpSource.SEARCH_CHANGED: SsdpChange.ALIVE, + SsdpSource.ADVERTISEMENT_ALIVE: SsdpChange.ALIVE, + SsdpSource.ADVERTISEMENT_BYEBYE: SsdpChange.BYEBYE, + SsdpSource.ADVERTISEMENT_UPDATE: SsdpChange.UPDATE, +} + + +@core_callback +def _async_process_callbacks( + hass: HomeAssistant, + callbacks: list[SsdpHassJobCallback], + discovery_info: _SsdpServiceInfo, + ssdp_change: SsdpChange, +) -> None: + for callback in callbacks: + try: + hass.async_run_hass_job( + callback, discovery_info, ssdp_change, background=True + ) + except Exception: + _LOGGER.exception("Failed to callback info: %s", discovery_info) + + +@core_callback +def _async_headers_match( + headers: CaseInsensitiveDict, lower_match_dict: dict[str, str] +) -> bool: + for header, val in lower_match_dict.items(): + if val == MATCH_ALL: + if header not in headers: + return False + elif headers.get_lower(header) != val: + return False + return True + + +class IntegrationMatchers: + """Optimized integration matching.""" + + def __init__(self) -> None: + """Init optimized integration matching.""" + self._match_by_key: ( + dict[str, dict[str, list[tuple[str, dict[str, str]]]]] | None + ) = None + + @core_callback + def async_setup( + self, integration_matchers: dict[str, list[dict[str, str]]] + ) -> None: + """Build matchers by key. + + Here we convert the primary match keys into their own + dicts so we can do lookups of the primary match + key to find the match dict. + """ + self._match_by_key = {} + for key in PRIMARY_MATCH_KEYS: + matchers_by_key = self._match_by_key[key] = {} + for domain, matchers in integration_matchers.items(): + for matcher in matchers: + if match_value := matcher.get(key): + matchers_by_key.setdefault(match_value, []).append( + (domain, matcher) + ) + + @core_callback + def async_matching_domains(self, info_with_desc: CaseInsensitiveDict) -> set[str]: + """Find domains matching the passed CaseInsensitiveDict.""" + assert self._match_by_key is not None + return { + domain + for key, matchers_by_key in self._match_by_key.items() + if (match_value := info_with_desc.get(key)) + for domain, matcher in matchers_by_key.get(match_value, ()) + if info_with_desc.items() >= matcher.items() + } + + +class Scanner: + """Class to manage SSDP searching and SSDP advertisements.""" + + def __init__( + self, hass: HomeAssistant, integration_matchers: IntegrationMatchers + ) -> None: + """Initialize class.""" + self.hass = hass + self._cancel_scan: Callable[[], None] | None = None + self._ssdp_listeners: list[SsdpListener] = [] + self._device_tracker = SsdpDeviceTracker() + self._callbacks: list[tuple[SsdpHassJobCallback, dict[str, str]]] = [] + self._description_cache: DescriptionCache | None = None + self.integration_matchers = integration_matchers + + @property + def _ssdp_devices(self) -> list[SsdpDevice]: + """Get all seen devices.""" + return list(self._device_tracker.devices.values()) + + async def async_register_callback( + self, callback: SsdpHassJobCallback, match_dict: dict[str, str] | None = None + ) -> Callable[[], None]: + """Register a callback.""" + if match_dict is None: + lower_match_dict = {} + else: + lower_match_dict = {k.lower(): v for k, v in match_dict.items()} + + # Make sure any entries that happened + # before the callback was registered are fired + for ssdp_device in self._ssdp_devices: + for headers in ssdp_device.all_combined_headers.values(): + if _async_headers_match(headers, lower_match_dict): + _async_process_callbacks( + self.hass, + [callback], + await self._async_headers_to_discovery_info( + ssdp_device, headers + ), + SsdpChange.ALIVE, + ) + + callback_entry = (callback, lower_match_dict) + self._callbacks.append(callback_entry) + + @core_callback + def _async_remove_callback() -> None: + self._callbacks.remove(callback_entry) + + return _async_remove_callback + + async def async_stop(self, *_: Any) -> None: + """Stop the scanner.""" + assert self._cancel_scan is not None + self._cancel_scan() + + await self._async_stop_ssdp_listeners() + + async def _async_stop_ssdp_listeners(self) -> None: + """Stop the SSDP listeners.""" + await asyncio.gather( + *( + create_eager_task(listener.async_stop()) + for listener in self._ssdp_listeners + ), + return_exceptions=True, + ) + + async def async_scan(self, *_: Any) -> None: + """Scan for new entries using ssdp listeners.""" + await self.async_scan_multicast() + await self.async_scan_broadcast() + + async def async_scan_multicast(self, *_: Any) -> None: + """Scan for new entries using multicase target.""" + for ssdp_listener in self._ssdp_listeners: + await ssdp_listener.async_search() + + async def async_scan_broadcast(self, *_: Any) -> None: + """Scan for new entries using broadcast target.""" + # Some sonos devices only seem to respond if we send to the broadcast + # address. This matches pysonos' behavior + # https://github.com/amelchio/pysonos/blob/d4329b4abb657d106394ae69357805269708c996/pysonos/discovery.py#L120 + for listener in self._ssdp_listeners: + if is_ipv4_address(listener.source): + await listener.async_search((str(IPV4_BROADCAST), SSDP_PORT)) + + async def async_start(self) -> None: + """Start the scanners.""" + session = async_get_clientsession(self.hass, verify_ssl=False) + requester = AiohttpSessionRequester(session, True, 10) + self._description_cache = DescriptionCache(requester) + + await self._async_start_ssdp_listeners() + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) + self._cancel_scan = async_track_time_interval( + self.hass, self.async_scan, SCAN_INTERVAL, name="SSDP scanner" + ) + + async_dispatcher_connect( + self.hass, + config_entries.signal_discovered_config_entry_removed(DOMAIN), + self._handle_config_entry_removed, + ) + + # Trigger the initial-scan. + await self.async_scan() + + async def _async_start_ssdp_listeners(self) -> None: + """Start the SSDP Listeners.""" + # Devices are shared between all sources. + for source_ip in await async_build_source_set(self.hass): + source_ip_str = str(source_ip) + if source_ip.version == 6: + source_tuple: AddressTupleVXType = ( + source_ip_str, + 0, + 0, + int(getattr(source_ip, "scope_id")), + ) + else: + source_tuple = (source_ip_str, 0) + source, target = determine_source_target(source_tuple) + source = fix_ipv6_address_scope_id(source) or source + self._ssdp_listeners.append( + SsdpListener( + callback=self._ssdp_listener_callback, + source=source, + target=target, + device_tracker=self._device_tracker, + ) + ) + results = await asyncio.gather( + *( + create_eager_task(listener.async_start()) + for listener in self._ssdp_listeners + ), + return_exceptions=True, + ) + failed_listeners = [] + for idx, result in enumerate(results): + if isinstance(result, Exception): + _LOGGER.debug( + "Failed to setup listener for %s: %s", + self._ssdp_listeners[idx].source, + result, + ) + failed_listeners.append(self._ssdp_listeners[idx]) + for listener in failed_listeners: + self._ssdp_listeners.remove(listener) + + @core_callback + def _async_get_matching_callbacks( + self, + combined_headers: CaseInsensitiveDict, + ) -> list[SsdpHassJobCallback]: + """Return a list of callbacks that match.""" + return [ + callback + for callback, lower_match_dict in self._callbacks + if _async_headers_match(combined_headers, lower_match_dict) + ] + + def _ssdp_listener_callback( + self, + ssdp_device: SsdpDevice, + dst: DeviceOrServiceType, + source: SsdpSource, + ) -> None: + """Handle a device/service change.""" + _LOGGER.debug( + "SSDP: ssdp_device: %s, dst: %s, source: %s", ssdp_device, dst, source + ) + + assert self._description_cache + + location = ssdp_device.location + _, info_desc = self._description_cache.peek_description_dict(location) + if info_desc is None: + # Fetch info desc in separate task and process from there. + self.hass.async_create_background_task( + self._ssdp_listener_process_callback_with_lookup( + ssdp_device, dst, source + ), + name=f"ssdp_info_desc_lookup_{location}", + eager_start=True, + ) + return + + # Info desc known, process directly. + self._ssdp_listener_process_callback(ssdp_device, dst, source, info_desc) + + async def _ssdp_listener_process_callback_with_lookup( + self, + ssdp_device: SsdpDevice, + dst: DeviceOrServiceType, + source: SsdpSource, + ) -> None: + """Handle a device/service change.""" + location = ssdp_device.location + self._ssdp_listener_process_callback( + ssdp_device, + dst, + source, + await self._async_get_description_dict(location), + ) + + def _ssdp_listener_process_callback( + self, + ssdp_device: SsdpDevice, + dst: DeviceOrServiceType, + source: SsdpSource, + info_desc: Mapping[str, Any], + skip_callbacks: bool = False, + ) -> None: + """Handle a device/service change.""" + matching_domains: set[str] = set() + combined_headers = ssdp_device.combined_headers(dst) + callbacks = self._async_get_matching_callbacks(combined_headers) + + # If there are no changes from a search, do not trigger a config flow + if source != SsdpSource.SEARCH_ALIVE: + matching_domains = self.integration_matchers.async_matching_domains( + CaseInsensitiveDict(combined_headers.as_dict(), **info_desc) + ) + + if ( + not callbacks + and not matching_domains + and source != SsdpSource.ADVERTISEMENT_BYEBYE + ): + return + + discovery_info = discovery_info_from_headers_and_description( + ssdp_device, combined_headers, info_desc + ) + discovery_info.x_homeassistant_matching_domains = matching_domains + + if callbacks and not skip_callbacks: + ssdp_change = SSDP_SOURCE_SSDP_CHANGE_MAPPING[source] + _async_process_callbacks(self.hass, callbacks, discovery_info, ssdp_change) + + # Config flows should only be created for alive/update messages from alive devices + if source == SsdpSource.ADVERTISEMENT_BYEBYE: + self._async_dismiss_discoveries(discovery_info) + return + + _LOGGER.debug("Discovery info: %s", discovery_info) + + if not matching_domains: + return # avoid creating DiscoveryKey if there are no matches + + discovery_key = discovery_flow.DiscoveryKey( + domain=DOMAIN, key=ssdp_device.udn, version=1 + ) + for domain in matching_domains: + _LOGGER.debug("Discovered %s at %s", domain, ssdp_device.location) + discovery_flow.async_create_flow( + self.hass, + domain, + {"source": config_entries.SOURCE_SSDP}, + discovery_info, + discovery_key=discovery_key, + ) + + def _async_dismiss_discoveries( + 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, + lambda service_info: bool( + service_info.ssdp_st == byebye_discovery_info.ssdp_st + and service_info.ssdp_location == byebye_discovery_info.ssdp_location + ), + ): + self.hass.config_entries.flow.async_abort(flow["flow_id"]) + + async def _async_get_description_dict( + self, location: str | None + ) -> Mapping[str, str]: + """Get description dict.""" + assert self._description_cache is not None + cache = self._description_cache + + has_description, description = cache.peek_description_dict(location) + if has_description: + return description or {} + + return await cache.async_get_description_dict(location) or {} + + async def _async_headers_to_discovery_info( + self, ssdp_device: SsdpDevice, headers: CaseInsensitiveDict + ) -> _SsdpServiceInfo: + """Combine the headers and description into discovery_info. + + Building this is a bit expensive so we only do it on demand. + """ + location = headers["location"] + info_desc = await self._async_get_description_dict(location) + return discovery_info_from_headers_and_description( + ssdp_device, headers, info_desc + ) + + async def async_get_discovery_info_by_udn_st( + self, udn: str, st: str + ) -> _SsdpServiceInfo | None: + """Return discovery_info for a udn and st.""" + for ssdp_device in self._ssdp_devices: + if ssdp_device.udn == udn: + if headers := ssdp_device.combined_headers(st): + return await self._async_headers_to_discovery_info( + ssdp_device, headers + ) + return None + + 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) + for ssdp_device in self._ssdp_devices + if (headers := ssdp_device.combined_headers(st)) + ] + + 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) + for ssdp_device in self._ssdp_devices + for headers in ssdp_device.all_combined_headers.values() + if ssdp_device.udn == udn + ] + + @core_callback + def _handle_config_entry_removed( + self, + entry: config_entries.ConfigEntry, + ) -> None: + """Handle config entry changes.""" + if TYPE_CHECKING: + assert self._description_cache is not None + cache = self._description_cache + for discovery_key in entry.discovery_keys[DOMAIN]: + if discovery_key.version != 1 or not isinstance(discovery_key.key, str): + continue + udn = discovery_key.key + _LOGGER.debug("Rediscover service %s", udn) + + for ssdp_device in self._ssdp_devices: + if ssdp_device.udn != udn: + continue + for dst in ssdp_device.all_combined_headers: + has_cached_desc, info_desc = cache.peek_description_dict( + ssdp_device.location + ) + if has_cached_desc and info_desc: + self._ssdp_listener_process_callback( + ssdp_device, + dst, + SsdpSource.SEARCH, + info_desc, + True, # Skip integration callbacks + ) + + +def discovery_info_from_headers_and_description( + ssdp_device: SsdpDevice, + combined_headers: CaseInsensitiveDict, + info_desc: Mapping[str, Any], +) -> _SsdpServiceInfo: + """Convert headers and description to discovery_info.""" + ssdp_usn = combined_headers["usn"] + ssdp_st = combined_headers.get_lower("st") + if isinstance(info_desc, CaseInsensitiveDict): + upnp_info = {**info_desc.as_dict()} + else: + upnp_info = {**info_desc} + + # Increase compatibility: depending on the message type, + # either the ST (Search Target, from M-SEARCH messages) + # or NT (Notification Type, from NOTIFY messages) header is mandatory + if not ssdp_st: + ssdp_st = combined_headers["nt"] + + # Ensure UPnP "udn" is set + if _ATTR_UPNP_UDN not in upnp_info: + if udn := _udn_from_usn(ssdp_usn): + upnp_info[_ATTR_UPNP_UDN] = udn + + return _SsdpServiceInfo( + ssdp_usn=ssdp_usn, + ssdp_st=ssdp_st, + ssdp_ext=combined_headers.get_lower("ext"), + ssdp_server=combined_headers.get_lower("server"), + ssdp_location=combined_headers.get_lower("location"), + ssdp_udn=combined_headers.get_lower("_udn"), + ssdp_nt=combined_headers.get_lower("nt"), + ssdp_headers=combined_headers, + upnp=upnp_info, + ssdp_all_locations=set(ssdp_device.locations), + ) + + +def _udn_from_usn(usn: str | None) -> str | None: + """Get the UDN from the USN.""" + if usn is None: + return None + if usn.startswith("uuid:"): + return usn.split("::")[0] + return None diff --git a/homeassistant/components/ssdp/server.py b/homeassistant/components/ssdp/server.py new file mode 100644 index 00000000000..6d89263ab20 --- /dev/null +++ b/homeassistant/components/ssdp/server.py @@ -0,0 +1,217 @@ +"""The SSDP integration server.""" + +from __future__ import annotations + +import asyncio +import logging +import socket +from time import time +from typing import Any +from urllib.parse import urljoin +import xml.etree.ElementTree as ET + +from async_upnp_client.const import AddressTupleVXType, DeviceIcon, DeviceInfo +from async_upnp_client.server import UpnpServer, UpnpServerDevice, UpnpServerService +from async_upnp_client.ssdp import ( + determine_source_target, + fix_ipv6_address_scope_id, + is_ipv4_address, +) + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STARTED, + EVENT_HOMEASSISTANT_STOP, + __version__ as current_version, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers.instance_id import async_get as async_get_instance_id +from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.system_info import async_get_system_info + +from .common import async_build_source_set + +UPNP_SERVER_MIN_PORT = 40000 +UPNP_SERVER_MAX_PORT = 40100 + +_LOGGER = logging.getLogger(__name__) + + +class HassUpnpServiceDevice(UpnpServerDevice): + """Hass Device.""" + + DEVICE_DEFINITION = DeviceInfo( + device_type="urn:home-assistant.io:device:HomeAssistant:1", + friendly_name="filled_later_on", + manufacturer="Home Assistant", + manufacturer_url="https://www.home-assistant.io", + model_description=None, + model_name="filled_later_on", + model_number=current_version, + model_url="https://www.home-assistant.io", + serial_number="filled_later_on", + udn="filled_later_on", + upc=None, + presentation_url="https://my.home-assistant.io/", + url="/device.xml", + icons=[ + DeviceIcon( + mimetype="image/png", + width=1024, + height=1024, + depth=24, + url="/static/icons/favicon-1024x1024.png", + ), + DeviceIcon( + mimetype="image/png", + width=512, + height=512, + depth=24, + url="/static/icons/favicon-512x512.png", + ), + DeviceIcon( + mimetype="image/png", + width=384, + height=384, + depth=24, + url="/static/icons/favicon-384x384.png", + ), + DeviceIcon( + mimetype="image/png", + width=192, + height=192, + depth=24, + url="/static/icons/favicon-192x192.png", + ), + ], + xml=ET.Element("server_device"), + ) + EMBEDDED_DEVICES: list[type[UpnpServerDevice]] = [] + SERVICES: list[type[UpnpServerService]] = [] + + +async def _async_find_next_available_port(source: AddressTupleVXType) -> int: + """Get a free TCP port.""" + family = socket.AF_INET if is_ipv4_address(source) else socket.AF_INET6 + test_socket = socket.socket(family, socket.SOCK_STREAM) + test_socket.setblocking(False) + test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + for port in range(UPNP_SERVER_MIN_PORT, UPNP_SERVER_MAX_PORT): + addr = (source[0],) + (port,) + source[2:] + try: + test_socket.bind(addr) + except OSError: + if port == UPNP_SERVER_MAX_PORT - 1: + raise + else: + return port + + raise RuntimeError("unreachable") + + +class Server: + """Class to be visible via SSDP searching and advertisements.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize class.""" + self.hass = hass + self._upnp_servers: list[UpnpServer] = [] + + async def async_start(self) -> None: + """Start the server.""" + bus = self.hass.bus + bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) + bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, + self._async_start_upnp_servers, + ) + + async def _async_get_instance_udn(self) -> str: + """Get Unique Device Name for this instance.""" + instance_id = await async_get_instance_id(self.hass) + return f"uuid:{instance_id[0:8]}-{instance_id[8:12]}-{instance_id[12:16]}-{instance_id[16:20]}-{instance_id[20:32]}".upper() + + async def _async_start_upnp_servers(self, event: Event) -> None: + """Start the UPnP/SSDP servers.""" + # Update UDN with our instance UDN. + udn = await self._async_get_instance_udn() + system_info = await async_get_system_info(self.hass) + model_name = system_info["installation_type"] + try: + presentation_url = get_url(self.hass, allow_ip=True, prefer_external=False) + except NoURLAvailableError: + _LOGGER.warning( + "Could not set up UPnP/SSDP server, as a presentation URL could" + " not be determined; Please configure your internal URL" + " in the Home Assistant general configuration" + ) + return + + serial_number = await async_get_instance_id(self.hass) + HassUpnpServiceDevice.DEVICE_DEFINITION = ( + HassUpnpServiceDevice.DEVICE_DEFINITION._replace( + udn=udn, + friendly_name=f"{self.hass.config.location_name} (Home Assistant)", + model_name=model_name, + presentation_url=presentation_url, + serial_number=serial_number, + ) + ) + + # Update icon URLs. + for index, icon in enumerate(HassUpnpServiceDevice.DEVICE_DEFINITION.icons): + new_url = urljoin(presentation_url, icon.url) + HassUpnpServiceDevice.DEVICE_DEFINITION.icons[index] = icon._replace( + url=new_url + ) + + # Start a server on all source IPs. + boot_id = int(time()) + for source_ip in await async_build_source_set(self.hass): + source_ip_str = str(source_ip) + if source_ip.version == 6: + source_tuple: AddressTupleVXType = ( + source_ip_str, + 0, + 0, + int(getattr(source_ip, "scope_id")), + ) + else: + source_tuple = (source_ip_str, 0) + source, target = determine_source_target(source_tuple) + source = fix_ipv6_address_scope_id(source) or source + http_port = await _async_find_next_available_port(source) + _LOGGER.debug("Binding UPnP HTTP server to: %s:%s", source_ip, http_port) + self._upnp_servers.append( + UpnpServer( + source=source, + target=target, + http_port=http_port, + server_device=HassUpnpServiceDevice, + boot_id=boot_id, + ) + ) + results = await asyncio.gather( + *(upnp_server.async_start() for upnp_server in self._upnp_servers), + return_exceptions=True, + ) + failed_servers = [] + for idx, result in enumerate(results): + if isinstance(result, Exception): + _LOGGER.debug( + "Failed to setup server for %s: %s", + self._upnp_servers[idx].source, + result, + ) + failed_servers.append(self._upnp_servers[idx]) + for server in failed_servers: + self._upnp_servers.remove(server) + + async def async_stop(self, *_: Any) -> None: + """Stop the server.""" + await self._async_stop_upnp_servers() + + async def _async_stop_upnp_servers(self) -> None: + """Stop UPnP/SSDP servers.""" + for server in self._upnp_servers: + await server.async_stop() diff --git a/homeassistant/components/ssdp/websocket_api.py b/homeassistant/components/ssdp/websocket_api.py new file mode 100644 index 00000000000..5342ec8035b --- /dev/null +++ b/homeassistant/components/ssdp/websocket_api.py @@ -0,0 +1,69 @@ +"""The ssdp integration websocket apis.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any, Final + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HassJob, HomeAssistant, callback +from homeassistant.helpers.json import json_bytes +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + SsdpServiceInfo, +) + +from .const import DOMAIN, SSDP_SCANNER +from .scanner import Scanner, SsdpChange + +FIELD_SSDP_ST: Final = "ssdp_st" +FIELD_SSDP_LOCATION: Final = "ssdp_location" + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the ssdp websocket API.""" + websocket_api.async_register_command(hass, ws_subscribe_discovery) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "ssdp/subscribe_discovery", + } +) +@websocket_api.async_response +async def ws_subscribe_discovery( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe advertisements websocket command.""" + scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] + msg_id: int = msg["id"] + + def _async_event_message(message: dict[str, Any]) -> None: + connection.send_message( + json_bytes(websocket_api.event_message(msg_id, message)) + ) + + @callback + def _async_on_data(info: SsdpServiceInfo, change: SsdpChange) -> None: + if change is not SsdpChange.BYEBYE: + _async_event_message( + { + "add": [ + {"name": info.upnp.get(ATTR_UPNP_FRIENDLY_NAME), **asdict(info)} + ] + } + ) + return + remove_msg = { + FIELD_SSDP_ST: info.ssdp_st, + FIELD_SSDP_LOCATION: info.ssdp_location, + } + _async_event_message({"remove": [remove_msg]}) + + job = HassJob(_async_on_data) + connection.send_message(json_bytes(websocket_api.result_message(msg_id))) + connection.subscriptions[msg_id] = await scanner.async_register_callback(job, None) diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 16988f1a9dc..916d0a9f26b 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -61,6 +61,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="fuel", translation_key="fuel", + device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py index 94a3bd1058b..d2824ab10e5 100644 --- a/homeassistant/components/stiebel_eltron/__init__.py +++ b/homeassistant/components/stiebel_eltron/__init__.py @@ -1,22 +1,29 @@ """The component for STIEBEL ELTRON heat pumps with ISGWeb Modbus module.""" -from datetime import timedelta import logging +from typing import Any from pymodbus.client import ModbusTcpClient -from pystiebeleltron import pystiebeleltron +from pystiebeleltron.pystiebeleltron import StiebelEltronAPI import voluptuous as vol -from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME, Platform +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + DEVICE_DEFAULT_NAME, + Platform, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle -CONF_HUB = "hub" -DEFAULT_HUB = "modbus_hub" +from .const import CONF_HUB, DEFAULT_HUB, DOMAIN + MODBUS_DOMAIN = "modbus" -DOMAIN = "stiebel_eltron" CONFIG_SCHEMA = vol.Schema( { @@ -31,39 +38,109 @@ CONFIG_SCHEMA = vol.Schema( ) _LOGGER = logging.getLogger(__name__) - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) +_PLATFORMS: list[Platform] = [Platform.CLIMATE] -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the STIEBEL ELTRON unit. +async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: + """Set up the STIEBEL ELTRON component.""" + hub_config: dict[str, Any] | None = None + if MODBUS_DOMAIN in config: + for hub in config[MODBUS_DOMAIN]: + if hub[CONF_NAME] == config[DOMAIN][CONF_HUB]: + hub_config = hub + break + if hub_config is None: + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_missing_hub", + breaks_in_ha_version="2025.11.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_missing_hub", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Stiebel Eltron", + }, + ) + return + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: hub_config[CONF_HOST], + CONF_PORT: hub_config[CONF_PORT], + CONF_NAME: config[DOMAIN][CONF_NAME], + }, + ) + if ( + result.get("type") is FlowResultType.ABORT + and result.get("reason") != "already_configured" + ): + ir.async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result['reason']}", + breaks_in_ha_version="2025.11.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": "Stiebel Eltron", + }, + ) + return - Will automatically load climate platform. - """ - name = config[DOMAIN][CONF_NAME] - modbus_client = hass.data[MODBUS_DOMAIN][config[DOMAIN][CONF_HUB]] + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2025.9.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Stiebel Eltron", + }, + ) - hass.data[DOMAIN] = { - "name": name, - "ste_data": StiebelEltronData(name, modbus_client), - } - discovery.load_platform(hass, Platform.CLIMATE, DOMAIN, {}, config) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the STIEBEL ELTRON component.""" + if DOMAIN in config: + hass.async_create_task(_async_import(hass, config)) return True -class StiebelEltronData: - """Get the latest data and update the states.""" +type StiebelEltronConfigEntry = ConfigEntry[StiebelEltronAPI] - def __init__(self, name: str, modbus_client: ModbusTcpClient) -> None: - """Init the STIEBEL ELTRON data object.""" - self.api = pystiebeleltron.StiebelEltronAPI(modbus_client, 1) +async def async_setup_entry( + hass: HomeAssistant, entry: StiebelEltronConfigEntry +) -> bool: + """Set up STIEBEL ELTRON from a config entry.""" + client = StiebelEltronAPI( + ModbusTcpClient(entry.data[CONF_HOST], port=entry.data[CONF_PORT]), 1 + ) - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self) -> None: - """Update unit data.""" - if not self.api.update(): - _LOGGER.warning("Modbus read failed") - else: - _LOGGER.debug("Data updated successfully") + success = await hass.async_add_executor_job(client.update) + if not success: + raise ConfigEntryNotReady("Could not connect to device") + + entry.runtime_data = client + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: StiebelEltronConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py index 4d302a0f70d..f10ef0df667 100644 --- a/homeassistant/components/stiebel_eltron/climate.py +++ b/homeassistant/components/stiebel_eltron/climate.py @@ -5,6 +5,8 @@ from __future__ import annotations import logging from typing import Any +from pystiebeleltron.pystiebeleltron import StiebelEltronAPI + from homeassistant.components.climate import ( PRESET_ECO, ClimateEntity, @@ -13,10 +15,9 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as STE_DOMAIN, StiebelEltronData +from . import StiebelEltronConfigEntry DEPENDENCIES = ["stiebel_eltron"] @@ -56,17 +57,14 @@ HA_TO_STE_HVAC = { HA_TO_STE_PRESET = {k: i for i, k in STE_TO_HA_PRESET.items()} -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: StiebelEltronConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the StiebelEltron platform.""" - name = hass.data[STE_DOMAIN]["name"] - ste_data = hass.data[STE_DOMAIN]["ste_data"] + """Set up STIEBEL ELTRON climate platform.""" - add_entities([StiebelEltron(name, ste_data)], True) + async_add_entities([StiebelEltron(entry.title, entry.runtime_data)], True) class StiebelEltron(ClimateEntity): @@ -81,7 +79,7 @@ class StiebelEltron(ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, name: str, ste_data: StiebelEltronData) -> None: + def __init__(self, name: str, client: StiebelEltronAPI) -> None: """Initialize the unit.""" self._name = name self._target_temperature: float | int | None = None @@ -89,19 +87,17 @@ class StiebelEltron(ClimateEntity): 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 + self._client = client def update(self) -> None: """Update unit attributes.""" - self._ste_data.update(no_throttle=self._force_update) - self._force_update = False + self._client.update() - self._target_temperature = self._ste_data.api.get_target_temp() - self._current_temperature = self._ste_data.api.get_current_temp() - self._current_humidity = self._ste_data.api.get_current_humidity() - self._filter_alarm = self._ste_data.api.get_filter_alarm_status() - self._operation = self._ste_data.api.get_operation() + self._target_temperature = self._client.get_target_temp() + self._current_temperature = self._client.get_current_temp() + self._current_humidity = self._client.get_current_humidity() + self._filter_alarm = self._client.get_filter_alarm_status() + self._operation = self._client.get_operation() _LOGGER.debug( "Update %s, current temp: %s", self._name, self._current_temperature @@ -170,20 +166,17 @@ class StiebelEltron(ClimateEntity): return new_mode = HA_TO_STE_HVAC.get(hvac_mode) _LOGGER.debug("set_hvac_mode: %s -> %s", self._operation, new_mode) - self._ste_data.api.set_operation(new_mode) - self._force_update = True + self._client.set_operation(new_mode) def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_temperature = kwargs.get(ATTR_TEMPERATURE) if target_temperature is not None: _LOGGER.debug("set_temperature: %s", target_temperature) - self._ste_data.api.set_target_temp(target_temperature) - self._force_update = True + self._client.set_target_temp(target_temperature) def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" new_mode = HA_TO_STE_PRESET.get(preset_mode) _LOGGER.debug("set_hvac_mode: %s -> %s", self._operation, new_mode) - self._ste_data.api.set_operation(new_mode) - self._force_update = True + self._client.set_operation(new_mode) diff --git a/homeassistant/components/stiebel_eltron/config_flow.py b/homeassistant/components/stiebel_eltron/config_flow.py new file mode 100644 index 00000000000..022fa50805a --- /dev/null +++ b/homeassistant/components/stiebel_eltron/config_flow.py @@ -0,0 +1,82 @@ +"""Config flow for the STIEBEL ELTRON integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pymodbus.client import ModbusTcpClient +from pystiebeleltron.pystiebeleltron import StiebelEltronAPI +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT + +from .const import DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class StiebelEltronConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for STIEBEL ELTRON.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + client = StiebelEltronAPI( + ModbusTcpClient(user_input[CONF_HOST], port=user_input[CONF_PORT]), 1 + ) + try: + success = await self.hass.async_add_executor_job(client.update) + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if not success: + errors["base"] = "cannot_connect" + if not errors: + return self.async_create_entry(title="Stiebel Eltron", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } + ), + errors=errors, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Handle import.""" + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + client = StiebelEltronAPI( + ModbusTcpClient(user_input[CONF_HOST], port=user_input[CONF_PORT]), 1 + ) + try: + success = await self.hass.async_add_executor_job(client.update) + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + if not success: + return self.async_abort(reason="cannot_connect") + + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + }, + ) diff --git a/homeassistant/components/stiebel_eltron/const.py b/homeassistant/components/stiebel_eltron/const.py new file mode 100644 index 00000000000..e6241caa77e --- /dev/null +++ b/homeassistant/components/stiebel_eltron/const.py @@ -0,0 +1,8 @@ +"""Constants for the STIEBEL ELTRON integration.""" + +DOMAIN = "stiebel_eltron" + +CONF_HUB = "hub" + +DEFAULT_HUB = "modbus_hub" +DEFAULT_PORT = 502 diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json index 9580cd4d4ca..f8140ed36d7 100644 --- a/homeassistant/components/stiebel_eltron/manifest.json +++ b/homeassistant/components/stiebel_eltron/manifest.json @@ -1,11 +1,10 @@ { "domain": "stiebel_eltron", "name": "STIEBEL ELTRON", - "codeowners": ["@fucm"], - "dependencies": ["modbus"], + "codeowners": ["@fucm", "@ThyMYthOS"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/stiebel_eltron", "iot_class": "local_polling", "loggers": ["pymodbus", "pystiebeleltron"], - "quality_scale": "legacy", - "requirements": ["pystiebeleltron==0.0.1.dev2"] + "requirements": ["pystiebeleltron==0.1.0"] } diff --git a/homeassistant/components/stiebel_eltron/strings.json b/homeassistant/components/stiebel_eltron/strings.json new file mode 100644 index 00000000000..8ff2b4025a9 --- /dev/null +++ b/homeassistant/components/stiebel_eltron/strings.json @@ -0,0 +1,43 @@ +{ + "config": { + "step": { + "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 Stiebel Eltron device.", + "port": "The port of your Stiebel Eltron device." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The {integration_title} YAML configuration is being removed", + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove both the `{domain}` and the relevant Modbus configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "YAML import failed due to a connection error", + "description": "Configuring {integration_title} using YAML is being removed but there was a connect 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_missing_hub": { + "title": "YAML import failed due to incomplete config", + "description": "Configuring {integration_title} using YAML is being removed but the configuration was not complete, thus we could not import your 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_unknown": { + "title": "YAML import failed due to an 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." + } + } +} diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index 416d56d1bdd..60183518c93 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -133,12 +133,15 @@ class DeviceConnectivity(SurePetcareBinarySensor): @callback def _update_attr(self, surepy_entity: SurepyEntity) -> None: - state = surepy_entity.raw_data()["status"] - 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}", - } - else: - self._attr_extra_state_attributes = {} + state = surepy_entity.raw_data().get("status", {}) + online = bool(state.get("online", False)) + self._attr_is_on = online + self._attr_extra_state_attributes = {} + if online: + device_rssi = state.get("signal", {}).get("device_rssi") + self._attr_extra_state_attributes["device_rssi"] = ( + f"{device_rssi:.2f}" if device_rssi else "Unknown" + ) + hub_rssi = state.get("signal", {}).get("hub_rssi") + if hub_rssi is not None: + self._attr_extra_state_attributes["hub_rssi"] = f"{hub_rssi:.2f}" diff --git a/homeassistant/components/swiss_public_transport/helper.py b/homeassistant/components/swiss_public_transport/helper.py index e41901337f4..72dc1afab8a 100644 --- a/homeassistant/components/swiss_public_transport/helper.py +++ b/homeassistant/components/swiss_public_transport/helper.py @@ -1,7 +1,7 @@ """Helper functions for swiss_public_transport.""" +from collections.abc import Mapping from datetime import timedelta -from types import MappingProxyType from typing import Any from opendata_transport import OpendataTransport @@ -36,7 +36,7 @@ def dict_duration_to_str_duration( return f"{d['hours']:02d}:{d['minutes']:02d}:{d['seconds']:02d}" -def unique_id_from_config(config: MappingProxyType[str, Any] | dict[str, Any]) -> str: +def unique_id_from_config(config: Mapping[str, Any]) -> str: """Build a unique id from a config entry.""" return ( f"{config[CONF_START]} {config[CONF_DESTINATION]}" diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 09bc157d4d2..8f417bc641a 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -66,6 +66,13 @@ PLATFORMS_BY_TYPE = { SupportedModels.RELAY_SWITCH_1.value: [Platform.SWITCH], SupportedModels.LEAK.value: [Platform.BINARY_SENSOR, Platform.SENSOR], SupportedModels.REMOTE.value: [Platform.SENSOR], + SupportedModels.ROLLER_SHADE.value: [ + Platform.COVER, + Platform.BINARY_SENSOR, + Platform.SENSOR, + ], + SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR], + SupportedModels.CIRCULATOR_FAN.value: [Platform.FAN, Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -80,6 +87,8 @@ CLASS_BY_DEVICE = { SupportedModels.BLIND_TILT.value: switchbot.SwitchbotBlindTilt, SupportedModels.RELAY_SWITCH_1PM.value: switchbot.SwitchbotRelaySwitch, SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch, + SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade, + SupportedModels.CIRCULATOR_FAN.value: switchbot.SwitchbotFan, } diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 16b41d75541..41bbb247929 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -35,6 +35,9 @@ class SupportedModels(StrEnum): RELAY_SWITCH_1 = "relay_switch_1" LEAK = "leak" REMOTE = "remote" + ROLLER_SHADE = "roller_shade" + HUBMINI_MATTER = "hubmini_matter" + CIRCULATOR_FAN = "circulator_fan" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -51,6 +54,8 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.HUB2: SupportedModels.HUB2, SwitchbotModel.RELAY_SWITCH_1PM: SupportedModels.RELAY_SWITCH_1PM, SwitchbotModel.RELAY_SWITCH_1: SupportedModels.RELAY_SWITCH_1, + SwitchbotModel.ROLLER_SHADE: SupportedModels.ROLLER_SHADE, + SwitchbotModel.CIRCULATOR_FAN: SupportedModels.CIRCULATOR_FAN, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -62,6 +67,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION, SwitchbotModel.LEAK: SupportedModels.LEAK, SwitchbotModel.REMOTE: SupportedModels.REMOTE, + SwitchbotModel.HUBMINI_MATTER: SupportedModels.HUBMINI_MATTER, } SUPPORTED_MODEL_TYPES = ( diff --git a/homeassistant/components/switchbot/coordinator.py b/homeassistant/components/switchbot/coordinator.py index 807132d13e8..3e3b59f9e06 100644 --- a/homeassistant/components/switchbot/coordinator.py +++ b/homeassistant/components/switchbot/coordinator.py @@ -91,6 +91,7 @@ class SwitchbotDataUpdateCoordinator(ActiveBluetoothDataUpdateCoordinator[None]) """Handle the device going unavailable.""" super()._async_handle_unavailable(service_info) self._was_unavailable = True + _LOGGER.info("Device %s is unavailable", self.device_name) @callback def _async_handle_bluetooth_event( @@ -114,6 +115,7 @@ class SwitchbotDataUpdateCoordinator(ActiveBluetoothDataUpdateCoordinator[None]) if not self.device.advertisement_changed(adv) and not self._was_unavailable: return self._was_unavailable = False + _LOGGER.info("Device %s is online", self.device_name) self.device.update_from_advertisement(adv) super()._async_handle_bluetooth_event(service_info, change) diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 5a9613ab2a2..bb73339aa05 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -37,6 +37,8 @@ async def async_setup_entry( coordinator = entry.runtime_data if isinstance(coordinator.device, switchbot.SwitchbotBlindTilt): async_add_entities([SwitchBotBlindTiltEntity(coordinator)]) + elif isinstance(coordinator.device, switchbot.SwitchbotRollerShade): + async_add_entities([SwitchBotRollerShadeEntity(coordinator)]) else: async_add_entities([SwitchBotCurtainEntity(coordinator)]) @@ -199,3 +201,85 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._attr_is_opening = self.parsed_data["motionDirection"]["opening"] self._attr_is_closing = self.parsed_data["motionDirection"]["closing"] self.async_write_ha_state() + + +class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity): + """Representation of a Switchbot.""" + + _device: switchbot.SwitchbotRollerShade + _attr_device_class = CoverDeviceClass.SHADE + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + _attr_translation_key = "cover" + _attr_name = None + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the switchbot.""" + super().__init__(coordinator) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if not last_state or ATTR_CURRENT_POSITION not in last_state.attributes: + return + + self._attr_current_cover_position = last_state.attributes.get( + ATTR_CURRENT_POSITION + ) + self._last_run_success = last_state.attributes.get("last_run_success") + if self._attr_current_cover_position is not None: + self._attr_is_closed = self._attr_current_cover_position <= 20 + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the roller shade.""" + + _LOGGER.debug("Switchbot to open roller shade %s", self._address) + self._last_run_success = bool(await self._device.open()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the roller shade.""" + + _LOGGER.debug("Switchbot to close roller shade %s", self._address) + self._last_run_success = bool(await self._device.close()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the moving of roller shade.""" + + _LOGGER.debug("Switchbot to stop roller shade %s", self._address) + self._last_run_success = bool(await self._device.stop()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + + position = kwargs.get(ATTR_POSITION) + _LOGGER.debug("Switchbot to move at %d %s", position, self._address) + self._last_run_success = bool(await self._device.set_position(position)) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_closing = self._device.is_closing() + self._attr_is_opening = self._device.is_opening() + self._attr_current_cover_position = self.parsed_data["position"] + self._attr_is_closed = self.parsed_data["position"] <= 20 + + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot/diagnostics.py b/homeassistant/components/switchbot/diagnostics.py new file mode 100644 index 00000000000..71c913c6411 --- /dev/null +++ b/homeassistant/components/switchbot/diagnostics.py @@ -0,0 +1,30 @@ +"""Diagnostics support for switchbot integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components import bluetooth +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .const import CONF_ENCRYPTION_KEY, CONF_KEY_ID +from .coordinator import SwitchbotConfigEntry + +TO_REDACT = [CONF_KEY_ID, CONF_ENCRYPTION_KEY] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: SwitchbotConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + service_info = bluetooth.async_last_service_info( + hass, coordinator.ble_device.address, connectable=coordinator.connectable + ) + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "service_info": service_info, + } diff --git a/homeassistant/components/switchbot/fan.py b/homeassistant/components/switchbot/fan.py new file mode 100644 index 00000000000..f704af309bf --- /dev/null +++ b/homeassistant/components/switchbot/fan.py @@ -0,0 +1,122 @@ +"""Support for SwitchBot Fans.""" + +from __future__ import annotations + +import logging +from typing import Any + +import switchbot +from switchbot import FanMode + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity + +_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SwitchbotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Switchbot fan based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities([SwitchBotFanEntity(coordinator)]) + + +class SwitchBotFanEntity(SwitchbotEntity, FanEntity, RestoreEntity): + """Representation of a Switchbot.""" + + _device: switchbot.SwitchbotFan + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.OSCILLATE + | FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + _attr_preset_modes = FanMode.get_modes() + _attr_translation_key = "fan" + _attr_name = None + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the switchbot.""" + super().__init__(coordinator) + self._attr_is_on = False + + @property + def is_on(self) -> bool | None: + """Return true if device is on.""" + return self._device.is_on() + + @property + def percentage(self) -> int | None: + """Return the current speed as a percentage.""" + return self._device.get_current_percentage() + + @property + def oscillating(self) -> bool | None: + """Return whether or not the fan is currently oscillating.""" + return self._device.get_oscillating_state() + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self._device.get_current_mode() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + + _LOGGER.debug( + "Switchbot fan to set preset mode %s %s", preset_mode, self._address + ) + self._last_run_success = bool(await self._device.set_preset_mode(preset_mode)) + self.async_write_ha_state() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + + _LOGGER.debug( + "Switchbot fan to set percentage %d %s", percentage, self._address + ) + self._last_run_success = bool(await self._device.set_percentage(percentage)) + self.async_write_ha_state() + + async def async_oscillate(self, oscillating: bool) -> None: + """Oscillate the fan.""" + + _LOGGER.debug( + "Switchbot fan to set oscillating %s %s", oscillating, self._address + ) + self._last_run_success = bool(await self._device.set_oscillation(oscillating)) + self.async_write_ha_state() + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + + _LOGGER.debug( + "Switchbot fan to set turn on %s %s %s", + percentage, + preset_mode, + self._address, + ) + self._last_run_success = bool(await self._device.turn_on()) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the fan.""" + + _LOGGER.debug("Switchbot fan to set turn off %s", self._address) + self._last_run_success = bool(await self._device.turn_off()) + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot/icons.json b/homeassistant/components/switchbot/icons.json new file mode 100644 index 00000000000..a1c1682d255 --- /dev/null +++ b/homeassistant/components/switchbot/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "fan": { + "fan": { + "state_attributes": { + "preset_mode": { + "state": { + "normal": "mdi:fan", + "natural": "mdi:leaf", + "sleep": "mdi:power-sleep", + "baby": "mdi:baby-face-outline" + } + } + } + } + } + } +} diff --git a/homeassistant/components/switchbot/lock.py b/homeassistant/components/switchbot/lock.py index 6bad154813a..d9ff2433cf8 100644 --- a/homeassistant/components/switchbot/lock.py +++ b/homeassistant/components/switchbot/lock.py @@ -13,6 +13,8 @@ from .const import CONF_LOCK_NIGHTLATCH, DEFAULT_LOCK_NIGHTLATCH from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .entity import SwitchbotEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index d9f6f98d1fd..176f85ab389 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.58.0"] + "requirements": ["PySwitchbot==0.60.1"] } diff --git a/homeassistant/components/switchbot/quality_scale.yaml b/homeassistant/components/switchbot/quality_scale.yaml index 3b8976aeb8e..e9d8a9626ac 100644 --- a/homeassistant/components/switchbot/quality_scale.yaml +++ b/homeassistant/components/switchbot/quality_scale.yaml @@ -7,7 +7,7 @@ rules: appropriate-polling: done brands: done common-modules: done - config-flow-test-coverage: todo + config-flow-test-coverage: done config-flow: done dependency-transparency: done docs-actions: @@ -16,7 +16,7 @@ rules: No custom actions 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 @@ -28,16 +28,17 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: done integration-owner: done - log-when-unavailable: todo - parallel-updates: - status: todo + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt comment: | - set `PARALLEL_UPDATES` in lock.py - reauthentication-flow: todo + Once a cryptographic key is successfully obtained for SwitchBot devices, + it will be granted perpetual validity with no expiration constraints. test-coverage: status: todo comment: | @@ -54,13 +55,13 @@ rules: status: done comment: | Can be improved: Device type scan filtering is applied to only show devices that are actually supported. - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: done - docs-supported-functions: todo + docs-supported-functions: done docs-troubleshooting: done - docs-use-cases: todo + docs-use-cases: done dynamic-devices: status: exempt comment: | @@ -68,10 +69,7 @@ rules: entity-category: done entity-device-class: done entity-disabled-by-default: done - entity-translations: - status: todo - comment: | - Needs to provide translations for hub2 temperature entity + entity-translations: done exception-translations: todo icon-translations: status: exempt diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index c9f93cce604..f0d075eafc9 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -160,6 +160,26 @@ } } } + }, + "fan": { + "fan": { + "state_attributes": { + "last_run_success": { + "state": { + "true": "[%key:component::binary_sensor::entity_component::problem::state::off%]", + "false": "[%key:component::binary_sensor::entity_component::problem::state::on%]" + } + }, + "preset_mode": { + "state": { + "normal": "Normal", + "natural": "Natural", + "sleep": "Sleep", + "baby": "Baby" + } + } + } + } } } } diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 44e130cc7a4..6f36739e2fc 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -140,11 +140,13 @@ async def make_device_data( hass, entry, api, device, coordinators_by_id ) devices_data.locks.append((device, coordinator)) + devices_data.sensors.append((device, coordinator)) if isinstance(device, Device) and device.device_type in ["Bot"]: coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id ) + devices_data.sensors.append((device, coordinator)) if coordinator.data is not None: if coordinator.data.get("deviceMode") == "pressMode": devices_data.buttons.append((device, coordinator)) diff --git a/homeassistant/components/switchbot_cloud/entity.py b/homeassistant/components/switchbot_cloud/entity.py index 74adcb049c1..5eb96ed3ac8 100644 --- a/homeassistant/components/switchbot_cloud/entity.py +++ b/homeassistant/components/switchbot_cloud/entity.py @@ -29,11 +29,15 @@ class SwitchBotCloudEntity(CoordinatorEntity[SwitchBotCoordinator]): super().__init__(coordinator) self._api = api self._attr_unique_id = device.device_id + _sw_version = None + if self.coordinator.data is not None: + _sw_version = self.coordinator.data.get("version") self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device.device_id)}, name=device.device_name, manufacturer="SwitchBot", model=device.device_type, + sw_version=_sw_version, ) async def send_api_command( diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 28384ffd4d5..9975bd49186 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -90,6 +90,7 @@ CO2_DESCRIPTION = SensorEntityDescription( ) SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { + "Bot": (BATTERY_DESCRIPTION,), "Meter": ( TEMPERATURE_DESCRIPTION, HUMIDITY_DESCRIPTION, @@ -133,6 +134,8 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { BATTERY_DESCRIPTION, CO2_DESCRIPTION, ), + "Smart Lock Pro": (BATTERY_DESCRIPTION,), + "Smart Lock": (BATTERY_DESCRIPTION,), } diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index 30597ed0738..efd07698eee 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -6,14 +6,10 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, cast -from aioswitcher.api import ( - DeviceState, - SwitcherApi, - SwitcherBaseResponse, - ThermostatSwing, -) +from aioswitcher.api import SwitcherApi +from aioswitcher.api.messages import SwitcherBaseResponse from aioswitcher.api.remotes import SwitcherBreezeRemote -from aioswitcher.device import DeviceCategory +from aioswitcher.device import DeviceCategory, DeviceState, ThermostatSwing from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index c8bf33eca09..1b5ac2bfc18 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -26,7 +26,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -117,20 +117,15 @@ class SwitcherClimateEntity(SwitcherEntity, ClimateEntity): self._attr_supported_features |= ( ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - self._update_data(True) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" self._update_data() - self.async_write_ha_state() - def _update_data(self, force_update: bool = False) -> None: + def _update_data(self) -> None: """Update data from device.""" data = cast(SwitcherThermostat, self.coordinator.data) features = self._remote.modes_features[data.mode] - if data.target_temperature == 0 and not force_update: + # Ignore empty update from device that was power cycled + if data.target_temperature == 0 and self.target_temperature is not None: return self._attr_current_temperature = data.temperature diff --git a/homeassistant/components/switcher_kis/config_flow.py b/homeassistant/components/switcher_kis/config_flow.py index e6c2e8e8589..ee015cb1a25 100644 --- a/homeassistant/components/switcher_kis/config_flow.py +++ b/homeassistant/components/switcher_kis/config_flow.py @@ -6,7 +6,7 @@ from collections.abc import Mapping import logging from typing import Any, Final -from aioswitcher.bridge import SwitcherBase +from aioswitcher.device import SwitcherBase from aioswitcher.device.tools import validate_token import voluptuous as vol @@ -21,8 +21,8 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA: Final = vol.Schema( { - vol.Required(CONF_USERNAME, default=""): str, - vol.Required(CONF_TOKEN, default=""): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_TOKEN): str, } ) @@ -32,9 +32,12 @@ class SwitcherFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - username: str | None = None - token: str | None = None - discovered_devices: dict[str, SwitcherBase] = {} + def __init__(self) -> None: + """Init the config flow.""" + super().__init__() + self.discovered_devices: dict[str, SwitcherBase] = {} + self.username: str | None = None + self.token: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index 5d8e4a4b0ac..c0ab90e1268 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -69,12 +69,6 @@ class SwitcherBaseCoverEntity(SwitcherEntity, CoverEntity): ) _cover_id: int - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._update_data() - self.async_write_ha_state() - def _update_data(self) -> None: """Update data from device.""" data = cast(SwitcherShutter, self.coordinator.data) diff --git a/homeassistant/components/switcher_kis/entity.py b/homeassistant/components/switcher_kis/entity.py index 82b892d548d..0cd56d2c462 100644 --- a/homeassistant/components/switcher_kis/entity.py +++ b/homeassistant/components/switcher_kis/entity.py @@ -6,6 +6,7 @@ from typing import Any from aioswitcher.api import SwitcherApi from aioswitcher.api.messages import SwitcherBaseResponse +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -28,6 +29,15 @@ class SwitcherEntity(CoordinatorEntity[SwitcherDataUpdateCoordinator]): connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_data() + super()._handle_coordinator_update() + + def _update_data(self) -> None: + """Update data from device.""" + async def _async_call_api(self, api: str, *args: Any, **kwargs: Any) -> None: """Call Switcher API.""" _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args) diff --git a/homeassistant/components/switcher_kis/icons.json b/homeassistant/components/switcher_kis/icons.json index bd770d3e656..6ca8e0e8351 100644 --- a/homeassistant/components/switcher_kis/icons.json +++ b/homeassistant/components/switcher_kis/icons.json @@ -20,9 +20,6 @@ }, "auto_shutdown": { "default": "mdi:progress-clock" - }, - "temperature": { - "default": "mdi:thermometer" } } }, diff --git a/homeassistant/components/switcher_kis/light.py b/homeassistant/components/switcher_kis/light.py index b9dc78f5bdf..77e2a8cdd97 100644 --- a/homeassistant/components/switcher_kis/light.py +++ b/homeassistant/components/switcher_kis/light.py @@ -59,31 +59,37 @@ class SwitcherBaseLightEntity(SwitcherEntity, LightEntity): control_result: bool | None = None _light_id: int - @callback - def _handle_coordinator_update(self) -> None: - """When device updates, clear control result that overrides state.""" - self.control_result = None - self.async_write_ha_state() + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + light_id: int, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._light_id = light_id + self.control_result: bool | None = None + self._update_data() - @property - def is_on(self) -> bool: - """Return True if entity is on.""" + def _update_data(self) -> None: + """Update data from device.""" if self.control_result is not None: - return self.control_result + self._attr_is_on = self.control_result + self.control_result = None + return data = cast(SwitcherLight, self.coordinator.data) - return bool(data.light[self._light_id] == DeviceState.ON) + self._attr_is_on = bool(data.light[self._light_id] == DeviceState.ON) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" await self._async_call_api(API_SET_LIGHT, DeviceState.ON, self._light_id) - self.control_result = True + self._attr_is_on = self.control_result = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" await self._async_call_api(API_SET_LIGHT, DeviceState.OFF, self._light_id) - self.control_result = False + self._attr_is_on = self.control_result = False self.async_write_ha_state() @@ -98,11 +104,7 @@ class SwitcherSingleLightEntity(SwitcherBaseLightEntity): light_id: int, ) -> None: """Initialize the entity.""" - super().__init__(coordinator) - self._light_id = light_id - self.control_result: bool | None = None - - # Entity class attributes + super().__init__(coordinator, light_id) self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" @@ -117,11 +119,7 @@ class SwitcherMultiLightEntity(SwitcherBaseLightEntity): light_id: int, ) -> None: """Initialize the entity.""" - super().__init__(coordinator) - self._light_id = light_id - self.control_result: bool | None = None - - # Entity class attributes + super().__init__(coordinator, light_id) self._attr_translation_placeholders = {"light_id": str(light_id + 1)} self._attr_unique_id = ( f"{coordinator.device_id}-{coordinator.mac_address}-{light_id}" diff --git a/homeassistant/components/switcher_kis/quality_scale.yaml b/homeassistant/components/switcher_kis/quality_scale.yaml new file mode 100644 index 00000000000..88f82f270d5 --- /dev/null +++ b/homeassistant/components/switcher_kis/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration uses entity services. + appropriate-polling: + status: exempt + comment: The integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: + status: todo + comment: make sure flows end with created entry or abort + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: todo + test-before-configure: done + test-before-setup: + status: exempt + comment: devices are setup asynchronously and marked as unavailable until they are ready. + unique-config-entry: + status: exempt + comment: The integration only supports a single config entry. + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: + status: exempt + comment: There is no option to discover devices without adding the integration. + docs-data-update: todo + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: + status: todo + comment: Migrate time sensors to timestamp or a duration device class + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: + status: exempt + comment: The integration does not have anything to reconfigure. + repair-issues: + status: exempt + comment: The integration does not have any issues to repair. + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: + status: todo + comment: validate_token method does not allow to pass websession + strict-typing: done diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index 029d517bb09..e918b8eb4c1 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -2,7 +2,17 @@ from __future__ import annotations -from aioswitcher.device import DeviceCategory +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast + +from aioswitcher.device import ( + DeviceCategory, + SwitcherBase, + SwitcherPowerBase, + SwitcherThermostatBase, + SwitcherTimedBase, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -11,7 +21,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfElectricCurrent, UnitOfPower +from homeassistant.const import UnitOfElectricCurrent, UnitOfPower, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -21,35 +31,50 @@ from .const import SIGNAL_DEVICE_ADD from .coordinator import SwitcherDataUpdateCoordinator from .entity import SwitcherEntity -POWER_SENSORS: list[SensorEntityDescription] = [ - SensorEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class SwitcherSensorEntityDescription(SensorEntityDescription): + """Class to describe a Switcher sensor entity.""" + + value_fn: Callable[[SwitcherBase], StateType] + + +POWER_SENSORS: list[SwitcherSensorEntityDescription] = [ + SwitcherSensorEntityDescription( key="power_consumption", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(SwitcherPowerBase, data).power_consumption, ), - SensorEntityDescription( + SwitcherSensorEntityDescription( key="electric_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(SwitcherPowerBase, data).electric_current, ), ] -TIME_SENSORS: list[SensorEntityDescription] = [ - SensorEntityDescription( +TIME_SENSORS: list[SwitcherSensorEntityDescription] = [ + SwitcherSensorEntityDescription( key="remaining_time", translation_key="remaining_time", + value_fn=lambda data: cast(SwitcherTimedBase, data).remaining_time, ), - SensorEntityDescription( + SwitcherSensorEntityDescription( key="auto_off_set", translation_key="auto_shutdown", entity_registry_enabled_default=False, + value_fn=lambda data: cast(SwitcherTimedBase, data).auto_shutdown, ), ] -TEMPERATURE_SENSORS: list[SensorEntityDescription] = [ - SensorEntityDescription( +TEMPERATURE_SENSORS: list[SwitcherSensorEntityDescription] = [ + SwitcherSensorEntityDescription( key="temperature", - translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(SwitcherThermostatBase, data).temperature, ), ] @@ -95,11 +120,11 @@ class SwitcherSensorEntity(SwitcherEntity, SensorEntity): def __init__( self, coordinator: SwitcherDataUpdateCoordinator, - description: SensorEntityDescription, + description: SwitcherSensorEntityDescription, ) -> None: """Initialize the entity.""" super().__init__(coordinator) - self.entity_description = description + self.entity_description: SwitcherSensorEntityDescription = description self._attr_unique_id = ( f"{coordinator.device_id}-{coordinator.mac_address}-{description.key}" @@ -108,4 +133,4 @@ class SwitcherSensorEntity(SwitcherEntity, SensorEntity): @property def native_value(self) -> StateType: """Return value of sensor.""" - return getattr(self.coordinator.data, self.entity_description.key) # type: ignore[no-any-return] + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index c3cf111199f..5eece295aa8 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -67,9 +67,6 @@ }, "auto_shutdown": { "name": "Auto shutdown" - }, - "temperature": { - "name": "Current temperature" } }, "switch": { @@ -83,11 +80,11 @@ }, "services": { "set_auto_off": { - "name": "Set auto off", - "description": "Updates Switcher device auto off setting.", + "name": "Set auto-off", + "description": "Updates Switcher device auto-off setting.", "fields": { "auto_off": { - "name": "Auto off", + "name": "Auto-off", "description": "Time period string containing hours and minutes." } } @@ -98,7 +95,7 @@ "fields": { "timer_minutes": { "name": "Timer", - "description": "Time to turn on." + "description": "Duration to turn on the Switcher." } } } diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 30b0b4161b1..1e602061c2c 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -6,8 +6,13 @@ from datetime import timedelta import logging from typing import Any, cast -from aioswitcher.api import Command, ShutterChildLock -from aioswitcher.device import DeviceCategory, DeviceState, SwitcherShutter +from aioswitcher.api import Command +from aioswitcher.device import ( + DeviceCategory, + DeviceState, + ShutterChildLock, + SwitcherShutter, +) import voluptuous as vol from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity @@ -83,11 +88,11 @@ async def async_setup_entry( number_of_covers = len(cast(SwitcherShutter, coordinator.data).position) if number_of_covers == 1: entities.append( - SwitchereShutterChildLockSingleSwitchEntity(coordinator, 0) + SwitcherShutterChildLockSingleSwitchEntity(coordinator, 0) ) else: entities.extend( - SwitchereShutterChildLockMultiSwitchEntity(coordinator, i) + SwitcherShutterChildLockMultiSwitchEntity(coordinator, i) for i in range(number_of_covers) ) async_add_entities(entities) @@ -106,34 +111,28 @@ class SwitcherBaseSwitchEntity(SwitcherEntity, SwitchEntity): """Initialize the entity.""" super().__init__(coordinator) self.control_result: bool | None = None - - # Entity class attributes self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" + self._update_data() - @callback - def _handle_coordinator_update(self) -> None: - """When device updates, clear control result that overrides state.""" - self.control_result = None - self.async_write_ha_state() - - @property - def is_on(self) -> bool: - """Return True if entity is on.""" + def _update_data(self) -> None: + """Update data from device.""" if self.control_result is not None: - return self.control_result + self._attr_is_on = self.control_result + self.control_result = None + return - return bool(self.coordinator.data.device_state == DeviceState.ON) + self._attr_is_on = bool(self.coordinator.data.device_state == DeviceState.ON) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self._async_call_api(API_CONTROL_DEVICE, Command.ON) - self.control_result = True + self._attr_is_on = 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_CONTROL_DEVICE, Command.OFF) - self.control_result = False + self._attr_is_on = self.control_result = False self.async_write_ha_state() async def async_set_auto_off_service(self, auto_off: timedelta) -> None: @@ -172,44 +171,45 @@ class SwitcherWaterHeaterSwitchEntity(SwitcherBaseSwitchEntity): async def async_turn_on_with_timer_service(self, timer_minutes: int) -> None: """Use for turning device on with a timer service calls.""" await self._async_call_api(API_CONTROL_DEVICE, Command.ON, timer_minutes) - self.control_result = True + self._attr_is_on = self.control_result = True self.async_write_ha_state() -class SwitchereShutterChildLockBaseSwitchEntity(SwitcherEntity, SwitchEntity): - """Representation of a Switcher shutter base switch entity.""" +class SwitcherShutterChildLockBaseSwitchEntity(SwitcherEntity, SwitchEntity): + """Representation of a Switcher child lock 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: + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + cover_id: int, + ) -> None: """Initialize the entity.""" super().__init__(coordinator) + self._cover_id = cover_id self.control_result: bool | None = None + self._update_data() - @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.""" + def _update_data(self) -> None: + """Update data from device.""" if self.control_result is not None: - return self.control_result + self._attr_is_on = self.control_result + self.control_result = None + return data = cast(SwitcherShutter, self.coordinator.data) - return bool(data.child_lock[self._cover_id] == ShutterChildLock.ON) + self._attr_is_on = 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._attr_is_on = self.control_result = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: @@ -217,12 +217,12 @@ class SwitchereShutterChildLockBaseSwitchEntity(SwitcherEntity, SwitchEntity): await self._async_call_api( API_SET_CHILD_LOCK, ShutterChildLock.OFF, self._cover_id ) - self.control_result = False + self._attr_is_on = self.control_result = False self.async_write_ha_state() -class SwitchereShutterChildLockSingleSwitchEntity( - SwitchereShutterChildLockBaseSwitchEntity +class SwitcherShutterChildLockSingleSwitchEntity( + SwitcherShutterChildLockBaseSwitchEntity ): """Representation of a Switcher runner child lock single switch entity.""" @@ -234,16 +234,14 @@ class SwitchereShutterChildLockSingleSwitchEntity( cover_id: int, ) -> None: """Initialize the entity.""" - super().__init__(coordinator) - self._cover_id = cover_id - + super().__init__(coordinator, cover_id) self._attr_unique_id = ( f"{coordinator.device_id}-{coordinator.mac_address}-child_lock" ) -class SwitchereShutterChildLockMultiSwitchEntity( - SwitchereShutterChildLockBaseSwitchEntity +class SwitcherShutterChildLockMultiSwitchEntity( + SwitcherShutterChildLockBaseSwitchEntity ): """Representation of a Switcher runner child lock multiple switch entity.""" @@ -255,8 +253,7 @@ class SwitchereShutterChildLockMultiSwitchEntity( cover_id: int, ) -> None: """Initialize the entity.""" - super().__init__(coordinator) - self._cover_id = cover_id + super().__init__(coordinator, cover_id) self._attr_translation_placeholders = {"cover_id": str(cover_id + 1)} self._attr_unique_id = ( diff --git a/homeassistant/components/switcher_kis/utils.py b/homeassistant/components/switcher_kis/utils.py index 50bfb883e6c..44f906aef44 100644 --- a/homeassistant/components/switcher_kis/utils.py +++ b/homeassistant/components/switcher_kis/utils.py @@ -6,7 +6,8 @@ import asyncio import logging from aioswitcher.api.remotes import SwitcherBreezeRemoteManager -from aioswitcher.bridge import SwitcherBase, SwitcherBridge +from aioswitcher.bridge import SwitcherBridge +from aioswitcher.device import SwitcherBase from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import singleton diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index 2817f4c21ce..f514f538821 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -2,100 +2,31 @@ from __future__ import annotations -import asyncio -from datetime import timedelta -import logging +from pysyncthru import SyncThruAPINotSupported -from pysyncthru import ConnectionMode, SyncThru, SyncThruAPINotSupported - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, device_registry as dr -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import SyncThruConfigEntry, SyncthruCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SyncThruConfigEntry) -> bool: """Set up config entry.""" - session = aiohttp_client.async_get_clientsession(hass) - hass.data.setdefault(DOMAIN, {}) - printer = SyncThru( - entry.data[CONF_URL], session, connection_mode=ConnectionMode.API - ) - - async def async_update_data() -> SyncThru: - """Fetch data from the printer.""" - try: - async with asyncio.timeout(10): - await printer.update() - except SyncThruAPINotSupported as api_error: - # if an exception is thrown, printer does not support syncthru - _LOGGER.debug( - "Configured printer at %s does not provide SyncThru JSON API", - printer.url, - exc_info=api_error, - ) - raise - - # if the printer is offline, we raise an UpdateFailed - if printer.is_unknown_state(): - raise UpdateFailed(f"Configured printer at {printer.url} does not respond.") - return printer - - coordinator = DataUpdateCoordinator[SyncThru]( - hass, - _LOGGER, - config_entry=entry, - name=DOMAIN, - update_method=async_update_data, - update_interval=timedelta(seconds=30), - ) + coordinator = SyncthruCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator if isinstance(coordinator.last_exception, SyncThruAPINotSupported): # this means that the printer does not support the syncthru JSON API # and the config should simply be discarded return False - device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - configuration_url=printer.url, - connections=device_connections(printer), - manufacturer="Samsung", - identifiers=device_identifiers(printer), - model=printer.model(), - name=printer.hostname(), - ) - 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: SyncThruConfigEntry) -> bool: """Unload the config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN].pop(entry.entry_id, None) - return unload_ok - - -def device_identifiers(printer: SyncThru) -> set[tuple[str, str]] | None: - """Get device identifiers for device registry.""" - serial = printer.serial_number() - if serial is None: - return None - return {(DOMAIN, serial)} - - -def device_connections(printer: SyncThru) -> set[tuple[str, str]]: - """Get device connections for device registry.""" - if mac := printer.raw().get("identity", {}).get("mac_addr"): - return {(dr.CONNECTION_NETWORK_MAC, mac)} - return set() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index e6d26d22433..56edff38680 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -2,24 +2,21 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + from pysyncthru import SyncThru, SyncthruState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) -from . import device_identifiers -from .const import DOMAIN +from .coordinator import SyncThruConfigEntry +from .entity import SyncthruEntity SYNCTHRU_STATE_PROBLEM = { SyncthruState.INVALID: True, @@ -32,81 +29,47 @@ SYNCTHRU_STATE_PROBLEM = { } +@dataclass(frozen=True, kw_only=True) +class SyncThruBinarySensorDescription(BinarySensorEntityDescription): + """Describes Syncthru binary sensor entities.""" + + value_fn: Callable[[SyncThru], bool | None] + + +BINARY_SENSORS: tuple[SyncThruBinarySensorDescription, ...] = ( + SyncThruBinarySensorDescription( + key="online", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + value_fn=lambda printer: printer.is_online(), + ), + SyncThruBinarySensorDescription( + key="problem", + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda printer: SYNCTHRU_STATE_PROBLEM[printer.device_status()], + ), +) + + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SyncThruConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" - coordinator: DataUpdateCoordinator[SyncThru] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = config_entry.runtime_data - name: str = config_entry.data[CONF_NAME] - entities = [ - SyncThruOnlineSensor(coordinator, name), - SyncThruProblemSensor(coordinator, name), - ] - - async_add_entities(entities) + async_add_entities( + SyncThruBinarySensor(coordinator, description) for description in BINARY_SENSORS + ) -class SyncThruBinarySensor( - CoordinatorEntity[DataUpdateCoordinator[SyncThru]], BinarySensorEntity -): +class SyncThruBinarySensor(SyncthruEntity, BinarySensorEntity): """Implementation of an abstract Samsung Printer binary sensor platform.""" - def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self.syncthru = coordinator.data - self._attr_name = name - self._id_suffix = "" + entity_description: SyncThruBinarySensorDescription @property - def unique_id(self): - """Return unique ID for the sensor.""" - serial = self.syncthru.serial_number() - return f"{serial}{self._id_suffix}" if serial else None - - @property - def device_info(self) -> DeviceInfo | None: - """Return device information.""" - if (identifiers := device_identifiers(self.syncthru)) is None: - return None - return DeviceInfo( - identifiers=identifiers, - ) - - -class SyncThruOnlineSensor(SyncThruBinarySensor): - """Implementation of a sensor that checks whether is turned on/online.""" - - _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - - def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._id_suffix = "_online" - - @property - def is_on(self): - """Set the state to whether the printer is online.""" - return self.syncthru.is_online() - - -class SyncThruProblemSensor(SyncThruBinarySensor): - """Implementation of a sensor that checks whether the printer works correctly.""" - - _attr_device_class = BinarySensorDeviceClass.PROBLEM - - def __init__(self, syncthru, name): - """Initialize the sensor.""" - super().__init__(syncthru, name) - self._id_suffix = "_problem" - - @property - def is_on(self): - """Set the state to whether there is a problem with the printer.""" - return SYNCTHRU_STATE_PROBLEM[self.syncthru.device_status()] + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/syncthru/config_flow.py b/homeassistant/components/syncthru/config_flow.py index 1407814f838..c245b181cc2 100644 --- a/homeassistant/components/syncthru/config_flow.py +++ b/homeassistant/components/syncthru/config_flow.py @@ -1,7 +1,7 @@ """Config flow for Samsung SyncThru.""" import re -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from pysyncthru import ConnectionMode, SyncThru, SyncThruAPINotSupported @@ -44,12 +44,14 @@ class SyncThruConfigFlow(ConfigFlow, domain=DOMAIN): 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( - ATTR_UPNP_PRESENTATION_URL, - f"http://{urlparse(discovery_info.ssdp_location or '').hostname}/", - ) + norm_url = url_normalize( + discovery_info.upnp.get(ATTR_UPNP_PRESENTATION_URL) + or f"http://{urlparse(discovery_info.ssdp_location or '').hostname}/" ) + if TYPE_CHECKING: + # url_normalize only returns None if passed None, and we don't do that + assert norm_url is not None + self.url = norm_url for existing_entry in ( x for x in self._async_current_entries() if x.data[CONF_URL] == self.url diff --git a/homeassistant/components/syncthru/coordinator.py b/homeassistant/components/syncthru/coordinator.py new file mode 100644 index 00000000000..0b96b354436 --- /dev/null +++ b/homeassistant/components/syncthru/coordinator.py @@ -0,0 +1,46 @@ +"""Coordinator for Syncthru integration.""" + +import asyncio +from datetime import timedelta +import logging + +from pysyncthru import ConnectionMode, SyncThru + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +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 + +_LOGGER = logging.getLogger(__name__) + +type SyncThruConfigEntry = ConfigEntry[SyncthruCoordinator] + + +class SyncthruCoordinator(DataUpdateCoordinator[SyncThru]): + """Class to manage fetching Syncthru data.""" + + def __init__(self, hass: HomeAssistant, entry: SyncThruConfigEntry) -> None: + """Initialize the Syncthru coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.syncthru = SyncThru( + entry.data[CONF_URL], + async_get_clientsession(hass), + connection_mode=ConnectionMode.API, + ) + + async def _async_update_data(self) -> SyncThru: + async with asyncio.timeout(10): + await self.syncthru.update() + if self.syncthru.is_unknown_state(): + raise UpdateFailed( + f"Configured printer at {self.syncthru.url} does not respond." + ) + return self.syncthru diff --git a/homeassistant/components/syncthru/diagnostics.py b/homeassistant/components/syncthru/diagnostics.py new file mode 100644 index 00000000000..169d354ef76 --- /dev/null +++ b/homeassistant/components/syncthru/diagnostics.py @@ -0,0 +1,17 @@ +"""Diagnostics support for Syncthru.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from .coordinator import SyncThruConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: SyncThruConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return entry.runtime_data.data.raw() diff --git a/homeassistant/components/syncthru/entity.py b/homeassistant/components/syncthru/entity.py new file mode 100644 index 00000000000..3f1aecbf0d4 --- /dev/null +++ b/homeassistant/components/syncthru/entity.py @@ -0,0 +1,36 @@ +"""Base class for Syncthru entities.""" + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SyncthruCoordinator + + +class SyncthruEntity(CoordinatorEntity[SyncthruCoordinator]): + """Base class for Syncthru entities.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: SyncthruCoordinator, entity_description: EntityDescription + ) -> None: + """Initialize the Syncthru entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + serial_number = coordinator.syncthru.serial_number() + assert serial_number is not None + self._attr_unique_id = f"{serial_number}_{entity_description.key}" + connections = set() + if mac := coordinator.syncthru.raw().get("identity", {}).get("mac_addr"): + connections.add((dr.CONNECTION_NETWORK_MAC, mac)) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_number)}, + connections=connections, + configuration_url=coordinator.syncthru.url, + manufacturer="Samsung", + model=coordinator.syncthru.model(), + name=coordinator.syncthru.hostname(), + ) diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json index 461ce9bfd3a..a33cefd2c70 100644 --- a/homeassistant/components/syncthru/manifest.json +++ b/homeassistant/components/syncthru/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/syncthru", "iot_class": "local_polling", "loggers": ["pysyncthru"], - "requirements": ["PySyncThru==0.8.0", "url-normalize==1.4.3"], + "requirements": ["PySyncThru==0.8.0", "url-normalize==2.2.1"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:Printer:1", diff --git a/homeassistant/components/syncthru/quality_scale.yaml b/homeassistant/components/syncthru/quality_scale.yaml new file mode 100644 index 00000000000..bc65d0828ea --- /dev/null +++ b/homeassistant/components/syncthru/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: todo + config-flow: todo + dependency-transparency: done + 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: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: todo + comment: DHCP or zeroconf is still possible + discovery: + status: todo + comment: DHCP or zeroconf is still possible + 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 has a fixed single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: exempt + comment: | + This integration has a fixed single device. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index c2063bf6c0a..569bf65f37d 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -2,32 +2,19 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, cast + from pysyncthru import SyncThru, SyncthruState -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, PERCENTAGE +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) -from . import device_identifiers -from .const import DOMAIN - -COLORS = ["black", "cyan", "magenta", "yellow"] -DRUM_COLORS = COLORS -TONER_COLORS = COLORS -TRAYS = range(1, 6) -OUTPUT_TRAYS = range(6) -DEFAULT_MONITORED_CONDITIONS = [] -DEFAULT_MONITORED_CONDITIONS.extend([f"toner_{key}" for key in TONER_COLORS]) -DEFAULT_MONITORED_CONDITIONS.extend([f"drum_{key}" for key in DRUM_COLORS]) -DEFAULT_MONITORED_CONDITIONS.extend([f"tray_{key}" for key in TRAYS]) -DEFAULT_MONITORED_CONDITIONS.extend([f"output_tray_{key}" for key in OUTPUT_TRAYS]) +from .coordinator import SyncThruConfigEntry +from .entity import SyncthruEntity SYNCTHRU_STATE_HUMAN = { SyncthruState.INVALID: "invalid", @@ -40,212 +27,141 @@ SYNCTHRU_STATE_HUMAN = { } +@dataclass(frozen=True, kw_only=True) +class SyncThruSensorDescription(SensorEntityDescription): + """Describes a SyncThru sensor entity.""" + + value_fn: Callable[[SyncThru], str | None] + extra_state_attributes_fn: Callable[[SyncThru], dict[str, str | int]] | None = None + + +def get_toner_entity_description(color: str) -> SyncThruSensorDescription: + """Get toner entity description for a specific color.""" + return SyncThruSensorDescription( + key=f"toner_{color}", + translation_key=f"toner_{color}", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda printer: printer.toner_status().get(color, {}).get("remaining"), + extra_state_attributes_fn=lambda printer: printer.toner_status().get(color, {}), + ) + + +def get_drum_entity_description(color: str) -> SyncThruSensorDescription: + """Get drum entity description for a specific color.""" + return SyncThruSensorDescription( + key=f"drum_{color}", + translation_key=f"drum_{color}", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda printer: printer.drum_status().get(color, {}).get("remaining"), + extra_state_attributes_fn=lambda printer: printer.drum_status().get(color, {}), + ) + + +def get_input_tray_entity_description(tray: str) -> SyncThruSensorDescription: + """Get input tray entity description for a specific tray.""" + placeholders = {} + translation_key = f"tray_{tray}" + if "_" in tray: + _, identifier = tray.split("_") + placeholders["tray_number"] = identifier + translation_key = "tray" + return SyncThruSensorDescription( + key=f"tray_{tray}", + translation_key=translation_key, + entity_category=EntityCategory.DIAGNOSTIC, + translation_placeholders=placeholders, + value_fn=( + lambda printer: printer.input_tray_status().get(tray, {}).get("newError") + or "Ready" + ), + extra_state_attributes_fn=( + lambda printer: printer.input_tray_status().get(tray, {}) + ), + ) + + +def get_output_tray_entity_description(tray: int) -> SyncThruSensorDescription: + """Get output tray entity description for a specific tray.""" + return SyncThruSensorDescription( + key=f"output_tray_{tray}", + translation_key="output_tray", + entity_category=EntityCategory.DIAGNOSTIC, + translation_placeholders={"tray_number": str(tray)}, + value_fn=( + lambda printer: printer.output_tray_status().get(tray, {}).get("status") + or "Ready" + ), + extra_state_attributes_fn=( + lambda printer: cast( + dict[str, str | int], printer.output_tray_status().get(tray, {}) + ) + ), + ) + + +SENSOR_TYPES: tuple[SyncThruSensorDescription, ...] = ( + SyncThruSensorDescription( + key="active_alerts", + translation_key="active_alerts", + value_fn=lambda printer: printer.raw().get("GXI_ACTIVE_ALERT_TOTAL"), + ), + SyncThruSensorDescription( + key="main", + name=None, + value_fn=lambda printer: SYNCTHRU_STATE_HUMAN[printer.device_status()], + extra_state_attributes_fn=lambda printer: { + "display_text": printer.device_status_details(), + }, + ), +) + + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SyncThruConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" - coordinator: DataUpdateCoordinator[SyncThru] = hass.data[DOMAIN][ - config_entry.entry_id - ] - printer: SyncThru = coordinator.data + coordinator = config_entry.runtime_data + printer = coordinator.data supp_toner = printer.toner_status(filter_supported=True) supp_drum = printer.drum_status(filter_supported=True) supp_tray = printer.input_tray_status(filter_supported=True) supp_output_tray = printer.output_tray_status() - name: str = config_entry.data[CONF_NAME] - entities: list[SyncThruSensor] = [ - SyncThruMainSensor(coordinator, name), - SyncThruActiveAlertSensor(coordinator, name), + entities: list[SyncThruSensorDescription] = [ + get_toner_entity_description(color) for color in supp_toner ] - entities.extend(SyncThruTonerSensor(coordinator, name, key) for key in supp_toner) - entities.extend(SyncThruDrumSensor(coordinator, name, key) for key in supp_drum) - entities.extend( - SyncThruInputTraySensor(coordinator, name, key) for key in supp_tray - ) - entities.extend( - SyncThruOutputTraySensor(coordinator, name, int_key) - for int_key in supp_output_tray + entities.extend(get_drum_entity_description(color) for color in supp_drum) + entities.extend(get_input_tray_entity_description(key) for key in supp_tray) + entities.extend(get_output_tray_entity_description(key) for key in supp_output_tray) + + async_add_entities( + SyncThruSensor(coordinator, description) + for description in SENSOR_TYPES + tuple(entities) ) - async_add_entities(entities) - -class SyncThruSensor(CoordinatorEntity[DataUpdateCoordinator[SyncThru]], SensorEntity): +class SyncThruSensor(SyncthruEntity, SensorEntity): """Implementation of an abstract Samsung Printer sensor platform.""" _attr_icon = "mdi:printer" - - def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self.syncthru = coordinator.data - self._attr_name = name - self._id_suffix = "" + entity_description: SyncThruSensorDescription @property - def unique_id(self): - """Return unique ID for the sensor.""" - serial = self.syncthru.serial_number() - return f"{serial}{self._id_suffix}" if serial else None + def native_value(self) -> str | int | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) @property - def device_info(self) -> DeviceInfo | None: - """Return device information.""" - if (identifiers := device_identifiers(self.syncthru)) is None: - return None - return DeviceInfo( - identifiers=identifiers, - ) - - -class SyncThruMainSensor(SyncThruSensor): - """Implementation of the main sensor, conducting the actual polling. - - It also shows the detailed state and presents - the displayed current status message. - """ - - _attr_entity_registry_enabled_default = False - - def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._id_suffix = "_main" - - @property - def native_value(self): - """Set state to human readable version of syncthru status.""" - return SYNCTHRU_STATE_HUMAN[self.syncthru.device_status()] - - @property - def extra_state_attributes(self): - """Show current printer display text.""" - return { - "display_text": self.syncthru.device_status_details(), - } - - -class SyncThruTonerSensor(SyncThruSensor): - """Implementation of a Samsung Printer toner sensor platform.""" - - _attr_native_unit_of_measurement = PERCENTAGE - - def __init__( - self, coordinator: DataUpdateCoordinator[SyncThru], name: str, color: str - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._attr_name = f"{name} Toner {color}" - self._color = color - self._id_suffix = f"_toner_{color}" - - @property - def extra_state_attributes(self): - """Show all data returned for this toner.""" - return self.syncthru.toner_status().get(self._color, {}) - - @property - def native_value(self): - """Show amount of remaining toner.""" - return self.syncthru.toner_status().get(self._color, {}).get("remaining") - - -class SyncThruDrumSensor(SyncThruSensor): - """Implementation of a Samsung Printer drum sensor platform.""" - - _attr_native_unit_of_measurement = PERCENTAGE - - def __init__( - self, coordinator: DataUpdateCoordinator[SyncThru], name: str, color: str - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._attr_name = f"{name} Drum {color}" - self._color = color - self._id_suffix = f"_drum_{color}" - - @property - def extra_state_attributes(self): - """Show all data returned for this drum.""" - return self.syncthru.drum_status().get(self._color, {}) - - @property - def native_value(self): - """Show amount of remaining drum.""" - return self.syncthru.drum_status().get(self._color, {}).get("remaining") - - -class SyncThruInputTraySensor(SyncThruSensor): - """Implementation of a Samsung Printer input tray sensor platform.""" - - def __init__( - self, coordinator: DataUpdateCoordinator[SyncThru], name: str, number: str - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._attr_name = f"{name} Tray {number}" - self._number = number - self._id_suffix = f"_tray_{number}" - - @property - def extra_state_attributes(self): - """Show all data returned for this input tray.""" - return self.syncthru.input_tray_status().get(self._number, {}) - - @property - def native_value(self): - """Display ready unless there is some error, then display error.""" - tray_state = ( - self.syncthru.input_tray_status().get(self._number, {}).get("newError") - ) - if tray_state == "": - tray_state = "Ready" - return tray_state - - -class SyncThruOutputTraySensor(SyncThruSensor): - """Implementation of a Samsung Printer output tray sensor platform.""" - - def __init__( - self, coordinator: DataUpdateCoordinator[SyncThru], name: str, number: int - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._attr_name = f"{name} Output Tray {number}" - self._number = number - self._id_suffix = f"_output_tray_{number}" - - @property - def extra_state_attributes(self): - """Show all data returned for this output tray.""" - return self.syncthru.output_tray_status().get(self._number, {}) - - @property - def native_value(self): - """Display ready unless there is some error, then display error.""" - tray_state = ( - self.syncthru.output_tray_status().get(self._number, {}).get("status") - ) - if tray_state == "": - tray_state = "Ready" - return tray_state - - -class SyncThruActiveAlertSensor(SyncThruSensor): - """Implementation of a Samsung Printer active alerts sensor platform.""" - - def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._attr_name = f"{name} Active Alerts" - self._id_suffix = "_active_alerts" - - @property - def native_value(self): - """Show number of active alerts.""" - return self.syncthru.raw().get("GXI_ACTIVE_ALERT_TOTAL") + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the state attributes.""" + if self.entity_description.extra_state_attributes_fn: + return self.entity_description.extra_state_attributes_fn( + self.coordinator.data + ) + return None diff --git a/homeassistant/components/syncthru/strings.json b/homeassistant/components/syncthru/strings.json index c4087bdee04..d78d51db86d 100644 --- a/homeassistant/components/syncthru/strings.json +++ b/homeassistant/components/syncthru/strings.json @@ -23,5 +23,49 @@ } } } + }, + "entity": { + "sensor": { + "toner_black": { + "name": "Black toner level" + }, + "toner_cyan": { + "name": "Cyan toner level" + }, + "toner_magenta": { + "name": "Magenta toner level" + }, + "toner_yellow": { + "name": "Yellow toner level" + }, + "drum_black": { + "name": "Black drum level" + }, + "drum_cyan": { + "name": "Cyan drum level" + }, + "drum_magenta": { + "name": "Magenta drum level" + }, + "drum_yellow": { + "name": "Yellow drum level" + }, + "tray_mp": { + "name": "Multi-purpose tray" + }, + "tray_manual": { + "name": "Manual feed tray" + }, + "tray": { + "name": "Input tray {tray_number}" + }, + "output_tray": { + "name": "Output tray {tray_number}" + }, + "active_alerts": { + "name": "Active alerts", + "unit_of_measurement": "alerts" + } + } } } diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index 2e80624ca5d..8b4cf655388 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -9,6 +9,7 @@ import logging from awesomeversion import AwesomeVersion from synology_dsm import SynologyDSM +from synology_dsm.api.core.external_usb import SynoCoreExternalUSB from synology_dsm.api.core.security import SynoCoreSecurity from synology_dsm.api.core.system import SynoCoreSystem from synology_dsm.api.core.upgrade import SynoCoreUpgrade @@ -78,6 +79,7 @@ class SynoApi: self.system: SynoCoreSystem | None = None self.upgrade: SynoCoreUpgrade | None = None self.utilisation: SynoCoreUtilization | None = None + self.external_usb: SynoCoreExternalUSB | None = None # Should we fetch them self._fetching_entities: dict[str, set[str]] = {} @@ -90,6 +92,7 @@ class SynoApi: self._with_system = True self._with_upgrade = True self._with_utilisation = True + self._with_external_usb = True self._login_future: asyncio.Future[None] | None = None @@ -261,6 +264,9 @@ class SynoApi: self._with_information = bool( self._fetching_entities.get(SynoDSMInformation.API_KEY) ) + self._with_external_usb = bool( + self._fetching_entities.get(SynoCoreExternalUSB.API_KEY) + ) # Reset not used API, information is not reset since it's used in device_info if not self._with_security: @@ -322,6 +328,15 @@ class SynoApi: self.dsm.reset(self.utilisation) self.utilisation = None + if not self._with_external_usb: + LOGGER.debug( + "Disable external usb api from being updated for '%s'", + self._entry.unique_id, + ) + if self.external_usb: + self.dsm.reset(self.external_usb) + self.external_usb = None + async def _fetch_device_configuration(self) -> None: """Fetch initial device config.""" self.network = self.dsm.network @@ -366,6 +381,12 @@ class SynoApi: ) self.surveillance_station = self.dsm.surveillance_station + if self._with_external_usb: + LOGGER.debug( + "Enable external usb api updates for '%s'", self._entry.unique_id + ) + self.external_usb = self.dsm.external_usb + async def _syno_api_executer(self, api_call: Callable) -> None: """Synology api call wrapper.""" try: diff --git a/homeassistant/components/synology_dsm/diagnostics.py b/homeassistant/components/synology_dsm/diagnostics.py index a673be23096..5cba9ed5aac 100644 --- a/homeassistant/components/synology_dsm/diagnostics.py +++ b/homeassistant/components/synology_dsm/diagnostics.py @@ -32,6 +32,7 @@ async def async_get_config_entry_diagnostics( "uptime": dsm_info.uptime, "temperature": dsm_info.temperature, }, + "external_usb": {"devices": {}, "partitions": {}}, "network": {"interfaces": {}}, "storage": {"disks": {}, "volumes": {}}, "surveillance_station": {"cameras": {}, "camera_diagnostics": {}}, @@ -43,6 +44,27 @@ async def async_get_config_entry_diagnostics( }, } + if syno_api.external_usb is not None: + for device in syno_api.external_usb.get_devices.values(): + if device is not None: + diag_data["external_usb"]["devices"][device.device_id] = { + "name": device.device_name, + "manufacturer": device.device_manufacturer, + "model": device.device_product_name, + "type": device.device_type, + "status": device.device_status, + "size_total": device.device_size_total(False), + } + for partition in device.device_partitions.values(): + if partition is not None: + diag_data["external_usb"]["partitions"][partition.name_id] = { + "name": partition.partition_title, + "filesystem": partition.filesystem, + "share_name": partition.share_name, + "size_used": partition.partition_size_used(False), + "size_total": partition.partition_size_total(False), + } + if syno_api.network is not None: for intf in syno_api.network.interfaces: diag_data["network"]["interfaces"][intf["id"]] = { diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py index d8800282c21..85269b9c480 100644 --- a/homeassistant/components/synology_dsm/entity.py +++ b/homeassistant/components/synology_dsm/entity.py @@ -93,6 +93,7 @@ class SynologyDSMDeviceEntity( storage = api.storage information = api.information network = api.network + external_usb = api.external_usb assert information is not None assert storage is not None assert network is not None @@ -121,6 +122,26 @@ class SynologyDSMDeviceEntity( self._device_model = disk["model"].strip() self._device_firmware = disk["firm"] self._device_type = disk["diskType"] + elif "device" in description.key: + assert self._device_id is not None + assert external_usb is not None + for device in external_usb.get_devices.values(): + if device.device_name == self._device_id: + self._device_name = device.device_name + self._device_manufacturer = device.device_manufacturer + self._device_model = device.device_product_name + self._device_type = device.device_type + break + elif "partition" in description.key: + assert self._device_id is not None + assert external_usb is not None + for device in external_usb.get_devices.values(): + for partition in device.device_partitions.values(): + if partition.partition_title == self._device_id: + self._device_name = partition.partition_title + self._device_manufacturer = "Synology" + self._device_model = partition.filesystem + break self._attr_unique_id += f"_{self._device_id}" self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/synology_dsm/icons.json b/homeassistant/components/synology_dsm/icons.json index 3c4d028dc7a..cc3f42a33fd 100644 --- a/homeassistant/components/synology_dsm/icons.json +++ b/homeassistant/components/synology_dsm/icons.json @@ -22,6 +22,12 @@ "cpu_15min_load": { "default": "mdi:chip" }, + "device_size_total": { + "default": "mdi:chart-pie" + }, + "device_status": { + "default": "mdi:checkbox-marked-circle-outline" + }, "memory_real_usage": { "default": "mdi:memory" }, @@ -49,6 +55,15 @@ "network_down": { "default": "mdi:download" }, + "partition_percentage_used": { + "default": "mdi:chart-pie" + }, + "partition_size_total": { + "default": "mdi:chart-pie" + }, + "partition_size_used": { + "default": "mdi:chart-pie" + }, "volume_status": { "default": "mdi:checkbox-marked-circle-outline", "state": { diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 566885e3989..613938f078f 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from typing import cast +from synology_dsm.api.core.external_usb import SynoCoreExternalUSB from synology_dsm.api.core.utilization import SynoCoreUtilization from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.storage.storage import SynoStorage @@ -17,6 +18,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONF_DEVICES, CONF_DISKS, PERCENTAGE, EntityCategory, @@ -261,6 +263,53 @@ STORAGE_DISK_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, ), ) +EXTERNAL_USB_DISK_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( + SynologyDSMSensorEntityDescription( + api_key=SynoCoreExternalUSB.API_KEY, + key="device_status", + translation_key="device_status", + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreExternalUSB.API_KEY, + key="device_size_total", + translation_key="device_size_total", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), +) +EXTERNAL_USB_PARTITION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( + SynologyDSMSensorEntityDescription( + api_key=SynoCoreExternalUSB.API_KEY, + key="partition_size_total", + translation_key="partition_size_total", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreExternalUSB.API_KEY, + key="partition_size_used", + translation_key="partition_size_used", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreExternalUSB.API_KEY, + key="partition_percentage_used", + translation_key="partition_percentage_used", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), +) INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( @@ -294,8 +343,14 @@ async def async_setup_entry( coordinator = data.coordinator_central storage = api.storage assert storage is not None + external_usb = api.external_usb - entities: list[SynoDSMUtilSensor | SynoDSMStorageSensor | SynoDSMInfoSensor] = [ + entities: list[ + SynoDSMUtilSensor + | SynoDSMStorageSensor + | SynoDSMInfoSensor + | SynoDSMExternalUSBSensor + ] = [ SynoDSMUtilSensor(api, coordinator, description) for description in UTILISATION_SENSORS ] @@ -320,6 +375,32 @@ async def async_setup_entry( ] ) + # Handle all external usb + if external_usb is not None and external_usb.get_devices: + entities.extend( + [ + SynoDSMExternalUSBSensor( + api, coordinator, description, device.device_name + ) + for device in entry.data.get( + CONF_DEVICES, external_usb.get_devices.values() + ) + for description in EXTERNAL_USB_DISK_SENSORS + ] + ) + entities.extend( + [ + SynoDSMExternalUSBSensor( + api, coordinator, description, partition.partition_title + ) + for device in entry.data.get( + CONF_DEVICES, external_usb.get_devices.values() + ) + for partition in device.device_partitions.values() + for description in EXTERNAL_USB_PARTITION_SENSORS + ] + ) + entities.extend( [ SynoDSMInfoSensor(api, coordinator, description) @@ -396,6 +477,45 @@ class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor): ) +class SynoDSMExternalUSBSensor(SynologyDSMDeviceEntity, SynoDSMSensor): + """Representation a Synology Storage sensor.""" + + entity_description: SynologyDSMSensorEntityDescription + + def __init__( + self, + api: SynoApi, + coordinator: SynologyDSMCentralUpdateCoordinator, + description: SynologyDSMSensorEntityDescription, + device_id: str | None = None, + ) -> None: + """Initialize the Synology DSM external usb sensor entity.""" + super().__init__(api, coordinator, description, device_id) + + @property + def native_value(self) -> StateType: + """Return the state.""" + external_usb = self._api.external_usb + assert external_usb is not None + if "device" in self.entity_description.key: + for device in external_usb.get_devices.values(): + if device.device_name == self._device_id: + attr = getattr(device, self.entity_description.key) + break + elif "partition" in self.entity_description.key: + for device in external_usb.get_devices.values(): + for partition in device.device_partitions.values(): + if partition.partition_title == self._device_id: + attr = getattr(partition, self.entity_description.key) + break + if callable(attr): + attr = attr() + if attr is None: + return None + + return attr # type: ignore[no-any-return] + + class SynoDSMInfoSensor(SynoDSMSensor): """Representation a Synology information sensor.""" diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index f51184ef1cb..2589f04959c 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -28,7 +28,7 @@ "backup_path": "Path" }, "data_description": { - "backup_share": "Select the shared folder, where the automatic Home-Assistant backup should be stored.", + "backup_share": "Select the shared folder where the automatic Home Assistant backup should be stored.", "backup_path": "Define the path on the selected shared folder (will automatically be created, if not exist)." } }, @@ -54,14 +54,14 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "missing_data": "Missing data: please retry later or an other configuration", - "otp_failed": "Two-step authentication failed, retry with a new pass code", + "otp_failed": "Two-step authentication failed, retry with a new passcode", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "no_mac_address": "The MAC address is missing from the zeroconf record", + "no_mac_address": "The MAC address is missing from the Zeroconf record", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reconfigure_successful": "Re-configuration was successful" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "options": { @@ -113,6 +113,12 @@ "cpu_user_load": { "name": "CPU utilization (user)" }, + "device_size_total": { + "name": "Device size" + }, + "device_status": { + "name": "Status" + }, "disk_smart_status": { "name": "Status (smart)" }, @@ -149,6 +155,15 @@ "network_up": { "name": "Upload throughput" }, + "partition_percentage_used": { + "name": "Partition used" + }, + "partition_size_total": { + "name": "Partition size" + }, + "partition_size_used": { + "name": "Partition used space" + }, "temperature": { "name": "[%key:component::sensor::entity_component::temperature::name%]" }, diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index c7cae2f347b..d9226e7de6e 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -251,6 +251,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.GIGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=2, icon="mdi:speedometer", value=cpu_speed, ), @@ -261,6 +262,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, value=lambda data: data.cpu.temperature, ), SystemBridgeSensorEntityDescription( @@ -270,6 +272,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=2, value=lambda data: data.cpu.voltage, ), SystemBridgeSensorEntityDescription( @@ -284,6 +287,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=2, icon="mdi:memory", value=memory_free, ), @@ -291,6 +295,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( key="memory_used_percentage", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, icon="mdi:memory", value=lambda data: data.memory.virtual.percent, ), @@ -301,6 +306,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=2, icon="mdi:memory", value=memory_used, ), @@ -322,6 +328,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( translation_key="load", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=1, icon="mdi:percent", value=lambda data: data.cpu.usage, ), @@ -345,6 +352,7 @@ BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, value=lambda data: data.battery.percentage, ), SystemBridgeSensorEntityDescription( @@ -381,6 +389,7 @@ async def async_setup_entry( name=f"{partition.mount_point} space used", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, icon="mdi:harddisk", value=( lambda data, @@ -457,6 +466,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=0, icon="mdi:monitor", value=lambda data, k=index: display_refresh_rate(data, k), ), @@ -476,6 +486,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=0, icon="mdi:speedometer", value=lambda data, k=index: gpu_core_clock_speed(data, k), ), @@ -490,6 +501,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=0, icon="mdi:speedometer", value=lambda data, k=index: gpu_memory_clock_speed(data, k), ), @@ -503,6 +515,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=0, icon="mdi:memory", value=lambda data, k=index: gpu_memory_free(data, k), ), @@ -515,6 +528,7 @@ async def async_setup_entry( name=f"{gpu.name} memory used %", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, icon="mdi:memory", value=lambda data, k=index: gpu_memory_used_percentage(data, k), ), @@ -529,6 +543,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=0, icon="mdi:memory", value=lambda data, k=index: gpu_memory_used(data, k), ), @@ -569,6 +584,7 @@ async def async_setup_entry( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, value=lambda data, k=index: gpu_temperature(data, k), ), entry.data[CONF_PORT], @@ -580,6 +596,7 @@ async def async_setup_entry( name=f"{gpu.name} usage %", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, icon="mdi:percent", value=lambda data, k=index: gpu_usage_percentage(data, k), ), @@ -601,6 +618,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", + suggested_display_precision=2, value=lambda data, k=cpu.id: cpu_usage_per_cpu(data, k), ), entry.data[CONF_PORT], @@ -614,6 +632,7 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, icon="mdi:chip", + suggested_display_precision=2, value=lambda data, k=cpu.id: cpu_power_per_cpu(data, k), ), entry.data[CONF_PORT], diff --git a/homeassistant/components/tado/diagnostics.py b/homeassistant/components/tado/diagnostics.py new file mode 100644 index 00000000000..0426707c6a9 --- /dev/null +++ b/homeassistant/components/tado/diagnostics.py @@ -0,0 +1,20 @@ +"""Provides diagnostics for Tado.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import TadoConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: TadoConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a Tado config entry.""" + + return { + "data": config_entry.runtime_data.coordinator.data, + "mobile_devices": config_entry.runtime_data.mobile_coordinator.data, + } diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index eba13d469f3..b252a396689 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.18.11"] + "requirements": ["python-tado==0.18.14"] } diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 53de3969998..5d9c4237be8 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -53,7 +53,7 @@ "state_attributes": { "preset_mode": { "state": { - "auto": "Auto" + "auto": "[%key:common::state::auto%]" } } } @@ -139,7 +139,7 @@ "description": "Adds a meter reading to Tado Energy IQ.", "fields": { "config_entry": { - "name": "Config Entry", + "name": "Config entry", "description": "Config entry to add meter reading to." }, "reading": { diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py index 6569b40ada2..65ac69d89c7 100644 --- a/homeassistant/components/tailscale/binary_sensor.py +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -46,37 +46,61 @@ BINARY_SENSORS: tuple[TailscaleBinarySensorEntityDescription, ...] = ( key="client_supports_hair_pinning", translation_key="client_supports_hair_pinning", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.hair_pinning, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.hair_pinning + if device.client_connectivity is not None + else None + ), ), TailscaleBinarySensorEntityDescription( key="client_supports_ipv6", translation_key="client_supports_ipv6", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.ipv6, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.ipv6 + if device.client_connectivity is not None + else None + ), ), TailscaleBinarySensorEntityDescription( key="client_supports_pcp", translation_key="client_supports_pcp", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.pcp, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.pcp + if device.client_connectivity is not None + else None + ), ), TailscaleBinarySensorEntityDescription( key="client_supports_pmp", translation_key="client_supports_pmp", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.pmp, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.pmp + if device.client_connectivity is not None + else None + ), ), TailscaleBinarySensorEntityDescription( key="client_supports_udp", translation_key="client_supports_udp", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.udp, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.udp + if device.client_connectivity is not None + else None + ), ), TailscaleBinarySensorEntityDescription( key="client_supports_upnp", translation_key="client_supports_upnp", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.upnp, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.upnp + if device.client_connectivity is not None + else None + ), ), ) diff --git a/homeassistant/components/tailscale/manifest.json b/homeassistant/components/tailscale/manifest.json index 7d571fe0675..8c005888387 100644 --- a/homeassistant/components/tailscale/manifest.json +++ b/homeassistant/components/tailscale/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tailscale", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["tailscale==0.6.1"] + "requirements": ["tailscale==0.6.2"] } diff --git a/homeassistant/components/tasmota/strings.json b/homeassistant/components/tasmota/strings.json index 22af3304297..13edee55110 100644 --- a/homeassistant/components/tasmota/strings.json +++ b/homeassistant/components/tasmota/strings.json @@ -20,11 +20,11 @@ "issues": { "topic_duplicated": { "title": "Several Tasmota devices are sharing the same topic", - "description": "Several Tasmota devices are sharing the topic {topic}.\n\n Tasmota devices with this problem: {offenders}." + "description": "Several Tasmota devices are sharing the topic {topic}.\n\nTasmota devices with this problem: {offenders}." }, "topic_no_prefix": { "title": "Tasmota device {name} has an invalid MQTT topic", - "description": "Tasmota device {name} with IP {ip} does not include `%prefix%` in its fulltopic.\n\nEntities for this devices are disabled until the configuration has been corrected." + "description": "Tasmota device {name} with IP {ip} does not include `%prefix%` in its FullTopic.\n\nEntities for this device are disabled until the configuration has been corrected." } } } diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 15a73cf3de5..c3f832b0c54 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, CONF_NAME, + CONF_TRIGGERS, CONF_UNIQUE_ID, SERVICE_RELOAD, ) @@ -27,7 +28,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration from homeassistant.util.hass_dict import HassKey -from .const import CONF_MAX, CONF_MIN, CONF_STEP, CONF_TRIGGER, DOMAIN, PLATFORMS +from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN, PLATFORMS from .coordinator import TriggerUpdateCoordinator from .helpers import async_get_blueprints @@ -136,7 +137,7 @@ async def _process_config(hass: HomeAssistant, hass_config: ConfigType) -> None: coordinator_tasks: list[Coroutine[Any, Any, TriggerUpdateCoordinator]] = [] for conf_section in hass_config[DOMAIN]: - if CONF_TRIGGER in conf_section: + if CONF_TRIGGERS in conf_section: coordinator_tasks.append(init_coordinator(hass, conf_section)) continue diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 4e07d67f6e9..ca643653cec 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -3,15 +3,17 @@ from collections.abc import Callable from contextlib import suppress import logging +from typing import Any import voluptuous as vol from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.blueprint import ( - BLUEPRINT_INSTANCE_FIELDS, is_blueprint_instance_config, + schemas as blueprint_schemas, ) from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN @@ -21,9 +23,15 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config import async_log_schema_error, config_without_domain from homeassistant.const import ( + CONF_ACTION, + CONF_ACTIONS, CONF_BINARY_SENSORS, + CONF_CONDITION, + CONF_CONDITIONS, CONF_NAME, CONF_SENSORS, + CONF_TRIGGER, + CONF_TRIGGERS, CONF_UNIQUE_ID, CONF_VARIABLES, ) @@ -37,6 +45,7 @@ from homeassistant.setup import async_notify_setup_error from . import ( binary_sensor as binary_sensor_platform, button as button_platform, + cover as cover_platform, image as image_platform, light as light_platform, number as number_platform, @@ -45,14 +54,7 @@ from . import ( switch as switch_platform, weather as weather_platform, ) -from .const import ( - CONF_ACTION, - CONF_CONDITION, - CONF_TRIGGER, - DOMAIN, - PLATFORMS, - TemplateConfig, -) +from .const import DOMAIN, PLATFORMS, TemplateConfig from .helpers import async_get_blueprints PACKAGE_MERGE_HINT = "list" @@ -65,7 +67,7 @@ def ensure_domains_do_not_have_trigger_or_action(*keys: str) -> Callable[[dict], def validate(obj: dict): options = set(obj.keys()) if found_domains := domains.intersection(options): - invalid = {CONF_TRIGGER, CONF_ACTION} + invalid = {CONF_TRIGGERS, CONF_ACTIONS} if found_invalid := invalid.intersection(set(obj.keys())): raise vol.Invalid( f"Unsupported option(s) found for domain {found_domains.pop()}, please remove ({', '.join(found_invalid)}) from your configuration", @@ -76,13 +78,22 @@ def ensure_domains_do_not_have_trigger_or_action(*keys: str) -> Callable[[dict], return validate -CONFIG_SECTION_SCHEMA = vol.Schema( - vol.All( +def _backward_compat_schema(value: Any | None) -> Any: + """Backward compatibility for automations.""" + + value = cv.renamed(CONF_TRIGGER, CONF_TRIGGERS)(value) + value = cv.renamed(CONF_ACTION, CONF_ACTIONS)(value) + return cv.renamed(CONF_CONDITION, CONF_CONDITIONS)(value) + + +CONFIG_SECTION_SCHEMA = vol.All( + _backward_compat_schema, + vol.Schema( { vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_TRIGGER): cv.TRIGGER_SCHEMA, - vol.Optional(CONF_CONDITION): cv.CONDITIONS_SCHEMA, - vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TRIGGERS): cv.TRIGGER_SCHEMA, + vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA, + vol.Optional(CONF_ACTIONS): cv.SCRIPT_SCHEMA, vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, vol.Optional(NUMBER_DOMAIN): vol.All( cv.ensure_list, [number_platform.NUMBER_SCHEMA] @@ -117,19 +128,17 @@ CONFIG_SECTION_SCHEMA = vol.Schema( vol.Optional(SWITCH_DOMAIN): vol.All( cv.ensure_list, [switch_platform.SWITCH_SCHEMA] ), + vol.Optional(COVER_DOMAIN): vol.All( + cv.ensure_list, [cover_platform.COVER_SCHEMA] + ), }, - ensure_domains_do_not_have_trigger_or_action( - BUTTON_DOMAIN, LIGHT_DOMAIN, SWITCH_DOMAIN - ), - ) + ), + ensure_domains_do_not_have_trigger_or_action(BUTTON_DOMAIN, COVER_DOMAIN), ) -TEMPLATE_BLUEPRINT_INSTANCE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } -).extend(BLUEPRINT_INSTANCE_FIELDS.schema) +TEMPLATE_BLUEPRINT_SCHEMA = vol.All( + _backward_compat_schema, blueprint_schemas.BLUEPRINT_SCHEMA +) async def _async_resolve_blueprints( @@ -144,10 +153,11 @@ async def _async_resolve_blueprints( raw_config = dict(config) if is_blueprint_instance_config(config): - config = TEMPLATE_BLUEPRINT_INSTANCE_SCHEMA(config) blueprints = async_get_blueprints(hass) - blueprint_inputs = await blueprints.async_inputs_from_config(config) + blueprint_inputs = await blueprints.async_inputs_from_config( + _backward_compat_schema(config) + ) raw_blueprint_inputs = blueprint_inputs.config_with_inputs config = blueprint_inputs.async_substitute() @@ -164,7 +174,7 @@ async def _async_resolve_blueprints( # house input results for template entities. For Trigger based template entities # CONF_VARIABLES should not be removed because the variables are always # executed between the trigger and action. - if CONF_TRIGGER not in config and CONF_VARIABLES in config: + if CONF_TRIGGERS not in config and CONF_VARIABLES in config: config[platform][CONF_VARIABLES] = config.pop(CONF_VARIABLES) raw_config = dict(config) @@ -182,14 +192,14 @@ async def async_validate_config_section( validated_config = await _async_resolve_blueprints(hass, config) - if CONF_TRIGGER in validated_config: - validated_config[CONF_TRIGGER] = await async_validate_trigger_config( - hass, validated_config[CONF_TRIGGER] + if CONF_TRIGGERS in validated_config: + validated_config[CONF_TRIGGERS] = await async_validate_trigger_config( + hass, validated_config[CONF_TRIGGERS] ) - if CONF_CONDITION in validated_config: - validated_config[CONF_CONDITION] = await async_validate_conditions_config( - hass, validated_config[CONF_CONDITION] + if CONF_CONDITIONS in validated_config: + validated_config[CONF_CONDITIONS] = await async_validate_conditions_config( + hass, validated_config[CONF_CONDITIONS] ) return validated_config diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index f333d14797e..53c0fa3af13 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -1,22 +1,18 @@ """Constants for the Template Platform Components.""" -from homeassistant.components.blueprint import BLUEPRINT_SCHEMA from homeassistant.const import Platform from homeassistant.helpers.typing import ConfigType -CONF_ACTION = "action" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_ATTRIBUTES = "attributes" CONF_AVAILABILITY = "availability" CONF_AVAILABILITY_TEMPLATE = "availability_template" -CONF_CONDITION = "condition" CONF_MAX = "max" CONF_MIN = "min" CONF_OBJECT_ID = "object_id" CONF_PICTURE = "picture" CONF_PRESS = "press" CONF_STEP = "step" -CONF_TRIGGER = "trigger" CONF_TURN_OFF = "turn_off" CONF_TURN_ON = "turn_on" @@ -41,8 +37,6 @@ PLATFORMS = [ Platform.WEATHER, ] -TEMPLATE_BLUEPRINT_SCHEMA = BLUEPRINT_SCHEMA - class TemplateConfig(dict): """Dummy class to allow adding attributes.""" diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py index c11e9b6101b..a2823233336 100644 --- a/homeassistant/components/template/coordinator.py +++ b/homeassistant/components/template/coordinator.py @@ -5,7 +5,14 @@ import logging from typing import TYPE_CHECKING, Any, cast from homeassistant.components.blueprint import CONF_USE_BLUEPRINT -from homeassistant.const import CONF_PATH, CONF_VARIABLES, EVENT_HOMEASSISTANT_START +from homeassistant.const import ( + CONF_ACTIONS, + CONF_CONDITIONS, + CONF_PATH, + CONF_TRIGGERS, + CONF_VARIABLES, + EVENT_HOMEASSISTANT_START, +) from homeassistant.core import Context, CoreState, Event, HomeAssistant, callback from homeassistant.helpers import condition, discovery, trigger as trigger_helper from homeassistant.helpers.script import Script @@ -14,7 +21,7 @@ from homeassistant.helpers.trace import trace_get from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_ACTION, CONF_CONDITION, CONF_TRIGGER, DOMAIN, PLATFORMS +from .const import DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) @@ -84,17 +91,17 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): async def _attach_triggers(self, start_event: Event | None = None) -> None: """Attach the triggers.""" - if CONF_ACTION in self.config: + if CONF_ACTIONS in self.config: self._script = Script( self.hass, - self.config[CONF_ACTION], + self.config[CONF_ACTIONS], self.name, DOMAIN, ) - if CONF_CONDITION in self.config: + if CONF_CONDITIONS in self.config: self._cond_func = await condition.async_conditions_from_config( - self.hass, self.config[CONF_CONDITION], _LOGGER, "template entity" + self.hass, self.config[CONF_CONDITIONS], _LOGGER, "template entity" ) if start_event is not None: @@ -107,7 +114,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): self._unsub_trigger = await trigger_helper.async_initialize_triggers( self.hass, - self.config[CONF_TRIGGER], + self.config[CONF_TRIGGERS], action, DOMAIN, self.name, diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 7c9c0ea9d53..e15180173b4 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -21,20 +21,25 @@ from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FRIENDLY_NAME, + CONF_NAME, CONF_OPTIMISTIC, + CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN +from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN from .template_entity import ( + LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, rewrite_common_legacy_to_modern_conf, ) @@ -56,7 +61,9 @@ _VALID_STATES = [ "none", ] +CONF_POSITION = "position" CONF_POSITION_TEMPLATE = "position_template" +CONF_TILT = "tilt" CONF_TILT_TEMPLATE = "tilt_template" OPEN_ACTION = "open_cover" CLOSE_ACTION = "close_cover" @@ -74,7 +81,39 @@ TILT_FEATURES = ( | CoverEntityFeature.SET_TILT_POSITION ) +LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { + CONF_VALUE_TEMPLATE: CONF_STATE, + CONF_POSITION_TEMPLATE: CONF_POSITION, + CONF_TILT_TEMPLATE: CONF_TILT, +} + +DEFAULT_NAME = "Template Cover" + COVER_SCHEMA = vol.All( + vol.Schema( + { + vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, + vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Optional(CONF_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_POSITION): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_TILT): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, + } + ) + .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), + cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), +) + +LEGACY_COVER_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -98,29 +137,56 @@ COVER_SCHEMA = vol.All( ) PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA)} + {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(LEGACY_COVER_SCHEMA)} ) -async def _async_create_entities(hass: HomeAssistant, config): - """Create the Template cover.""" +def rewrite_legacy_to_modern_conf( + hass: HomeAssistant, config: dict[str, dict] +) -> list[dict]: + """Rewrite legacy switch configuration definitions to modern ones.""" covers = [] - for object_id, entity_config in config[CONF_COVERS].items(): - entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config) + for object_id, entity_conf in config.items(): + entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - unique_id = entity_config.get(CONF_UNIQUE_ID) + entity_conf = rewrite_common_legacy_to_modern_conf( + hass, entity_conf, LEGACY_FIELDS + ) + + if CONF_NAME not in entity_conf: + entity_conf[CONF_NAME] = template.Template(object_id, hass) + + covers.append(entity_conf) + + return covers + + +@callback +def _async_create_template_tracking_entities( + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: + """Create the template switches.""" + covers = [] + + for entity_conf in definitions: + unique_id = entity_conf.get(CONF_UNIQUE_ID) + + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" covers.append( CoverTemplate( hass, - object_id, - entity_config, + entity_conf, unique_id, ) ) - return covers + async_add_entities(covers) async def async_setup_platform( @@ -130,7 +196,21 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template cover.""" - async_add_entities(await _async_create_entities(hass, config)) + if discovery_info is None: + _async_create_template_tracking_entities( + async_add_entities, + hass, + rewrite_legacy_to_modern_conf(hass, config[CONF_COVERS]), + None, + ) + return + + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) class CoverTemplate(TemplateEntity, CoverEntity): @@ -141,23 +221,22 @@ class CoverTemplate(TemplateEntity, CoverEntity): def __init__( self, hass: HomeAssistant, - object_id, config: dict[str, Any], unique_id, ) -> None: """Initialize the Template cover.""" - super().__init__( - hass, config=config, fallback_name=object_id, unique_id=unique_id - ) - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) + super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + if (object_id := config.get(CONF_OBJECT_ID)) is not None: + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, object_id, hass=hass + ) name = self._attr_name if TYPE_CHECKING: assert name is not None - self._template = config.get(CONF_VALUE_TEMPLATE) - self._position_template = config.get(CONF_POSITION_TEMPLATE) - self._tilt_template = config.get(CONF_TILT_TEMPLATE) + self._template = config.get(CONF_STATE) + + self._position_template = config.get(CONF_POSITION) + self._tilt_template = config.get(CONF_TILT) self._attr_device_class = config.get(CONF_DEVICE_CLASS) # The config requires (open and close scripts) or a set position script, diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index f3bc26391a9..7ec62891784 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -149,17 +149,21 @@ class TemplateFan(TemplateEntity, FanEntity): self._oscillating_template = config.get(CONF_OSCILLATING_TEMPLATE) self._direction_template = config.get(CONF_DIRECTION_TEMPLATE) - for action_id in ( - CONF_ON_ACTION, - CONF_OFF_ACTION, - CONF_SET_PERCENTAGE_ACTION, - CONF_SET_PRESET_MODE_ACTION, - CONF_SET_OSCILLATING_ACTION, - CONF_SET_DIRECTION_ACTION, + self._attr_supported_features |= ( + FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON + ) + for action_id, supported_feature in ( + (CONF_ON_ACTION, 0), + (CONF_OFF_ACTION, 0), + (CONF_SET_PERCENTAGE_ACTION, FanEntityFeature.SET_SPEED), + (CONF_SET_PRESET_MODE_ACTION, FanEntityFeature.PRESET_MODE), + (CONF_SET_OSCILLATING_ACTION, FanEntityFeature.OSCILLATE), + (CONF_SET_DIRECTION_ACTION, FanEntityFeature.DIRECTION), ): # Scripts can be an empty list, therefore we need to check for None if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature self._state: bool | None = False self._percentage: int | None = None @@ -172,19 +176,6 @@ class TemplateFan(TemplateEntity, FanEntity): # List of valid preset modes self._preset_modes: list[str] | None = config.get(CONF_PRESET_MODES) - - if self._percentage_template: - self._attr_supported_features |= FanEntityFeature.SET_SPEED - if self._preset_mode_template and self._preset_modes: - self._attr_supported_features |= FanEntityFeature.PRESET_MODE - if self._oscillating_template: - self._attr_supported_features |= FanEntityFeature.OSCILLATE - if self._direction_template: - self._attr_supported_features |= FanEntityFeature.DIRECTION - self._attr_supported_features |= ( - FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON - ) - self._attr_assumed_state = self._template is None @property @@ -270,6 +261,8 @@ class TemplateFan(TemplateEntity, FanEntity): if self._template is None: self._state = percentage != 0 + + if self._template is None or self._percentage_template is None: self.async_write_ha_state() async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -285,32 +278,39 @@ class TemplateFan(TemplateEntity, FanEntity): if self._template is None: self._state = True + + if self._template is None or self._preset_mode_template is None: self.async_write_ha_state() async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation of the fan.""" - if (script := self._action_scripts.get(CONF_SET_OSCILLATING_ACTION)) is None: - return - self._oscillating = oscillating - await self.async_run_script( - script, - run_variables={ATTR_OSCILLATING: self.oscillating}, - context=self._context, - ) + if ( + script := self._action_scripts.get(CONF_SET_OSCILLATING_ACTION) + ) is not None: + await self.async_run_script( + script, + run_variables={ATTR_OSCILLATING: self.oscillating}, + context=self._context, + ) + + if self._oscillating_template is None: + self.async_write_ha_state() async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" - if (script := self._action_scripts.get(CONF_SET_DIRECTION_ACTION)) is None: - return - if direction in _VALID_DIRECTIONS: self._direction = direction - await self.async_run_script( - script, - run_variables={ATTR_DIRECTION: direction}, - context=self._context, - ) + if ( + script := self._action_scripts.get(CONF_SET_DIRECTION_ACTION) + ) is not None: + await self.async_run_script( + script, + run_variables={ATTR_DIRECTION: direction}, + context=self._context, + ) + if self._direction_template is None: + self.async_write_ha_state() else: _LOGGER.error( "Received invalid direction: %s for entity %s. Expected: %s", diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index d74a4a4ed00..660227f65dc 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.singleton import singleton -from .const import DOMAIN, TEMPLATE_BLUEPRINT_SCHEMA +from .const import DOMAIN from .entity import AbstractTemplateEntity DATA_BLUEPRINTS = "template_blueprints" @@ -54,6 +54,9 @@ async def _reload_blueprint_templates(hass: HomeAssistant, blueprint_path: str) @callback def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints: """Get template blueprints.""" + # pylint: disable-next=import-outside-toplevel + from .config import TEMPLATE_BLUEPRINT_SCHEMA + return blueprint.DomainBlueprints( hass, DOMAIN, diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index c58709eba5e..3b64cca26b4 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator, Sequence import logging from typing import TYPE_CHECKING, Any @@ -18,6 +19,7 @@ from homeassistant.components.light import ( ATTR_TRANSITION, DEFAULT_MAX_KELVIN, DEFAULT_MIN_KELVIN, + DOMAIN as LIGHT_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, ColorMode, @@ -46,6 +48,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util +from . import TriggerUpdateCoordinator from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, @@ -55,6 +58,7 @@ from .template_entity import ( TemplateEntity, rewrite_common_legacy_to_modern_conf, ) +from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] @@ -253,6 +257,13 @@ async def async_setup_platform( ) return + if "coordinator" in discovery_info: + async_add_entities( + TriggerLightEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + _async_create_template_tracking_entities( async_add_entities, hass, @@ -261,27 +272,17 @@ async def async_setup_platform( ) -class LightTemplate(TemplateEntity, LightEntity): - """Representation of a templated Light, including dimmable.""" - - _attr_should_poll = False +class AbstractTemplateLight(LightEntity): + """Representation of a template lights features.""" def __init__( - self, - hass: HomeAssistant, - config: dict[str, Any], - unique_id: str | None, + self, config: dict[str, Any], initial_state: bool | None = False ) -> None: - """Initialize the light.""" - super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) - name = self._attr_name - if TYPE_CHECKING: - assert name is not None + """Initialize the features.""" + self._registered_scripts: list[str] = [] + + # Template attributes self._template = config.get(CONF_STATE) self._level_template = config.get(CONF_LEVEL) self._temperature_template = config.get(CONF_TEMPERATURE) @@ -295,12 +296,8 @@ class LightTemplate(TemplateEntity, LightEntity): self._min_mireds_template = config.get(CONF_MIN_MIREDS) self._supports_transition_template = config.get(CONF_SUPPORTS_TRANSITION) - for action_id in (CONF_ON_ACTION, CONF_OFF_ACTION, CONF_EFFECT_ACTION): - # Scripts can be an empty list, therefore we need to check for None - if (action_config := config.get(action_id)) is not None: - self.add_script(action_id, action_config, name, DOMAIN) - - self._state = False + # Stored values for template attributes + self._state = initial_state self._brightness = None self._temperature: int | None = None self._hs_color = None @@ -309,14 +306,19 @@ class LightTemplate(TemplateEntity, LightEntity): self._rgbww_color = None self._effect = None self._effect_list = None - self._color_mode = None self._max_mireds = None self._min_mireds = None self._supports_transition = False - self._supported_color_modes = None + self._color_mode: ColorMode | None = None + self._supported_color_modes: set[ColorMode] | None = None - color_modes = {ColorMode.ONOFF} + def _register_scripts( + self, config: dict[str, Any] + ) -> Generator[tuple[str, Sequence[dict[str, Any]], ColorMode | None]]: for action_id, color_mode in ( + (CONF_ON_ACTION, None), + (CONF_OFF_ACTION, None), + (CONF_EFFECT_ACTION, None), (CONF_TEMPERATURE_ACTION, ColorMode.COLOR_TEMP), (CONF_LEVEL_ACTION, ColorMode.BRIGHTNESS), (CONF_HS_ACTION, ColorMode.HS), @@ -324,21 +326,9 @@ class LightTemplate(TemplateEntity, LightEntity): (CONF_RGBW_ACTION, ColorMode.RGBW), (CONF_RGBWW_ACTION, ColorMode.RGBWW), ): - # Scripts can be an empty list, therefore we need to check for None if (action_config := config.get(action_id)) is not None: - self.add_script(action_id, action_config, name, DOMAIN) - color_modes.add(color_mode) - self._supported_color_modes = filter_supported_color_modes(color_modes) - if len(self._supported_color_modes) > 1: - self._color_mode = ColorMode.UNKNOWN - if len(self._supported_color_modes) == 1: - self._color_mode = next(iter(self._supported_color_modes)) - - self._attr_supported_features = LightEntityFeature(0) - if (self._action_scripts.get(CONF_EFFECT_ACTION)) is not None: - self._attr_supported_features |= LightEntityFeature.EFFECT - if self._supports_transition is True: - self._attr_supported_features |= LightEntityFeature.TRANSITION + self._registered_scripts.append(action_id) + yield (action_id, action_config, color_mode) @property def brightness(self) -> int | None: @@ -413,107 +403,12 @@ class LightTemplate(TemplateEntity, LightEntity): """Return true if device is on.""" return self._state - @callback - def _async_setup_templates(self) -> None: - """Set up templates.""" - if self._template: - self.add_template_attribute( - "_state", self._template, None, self._update_state - ) - if self._level_template: - self.add_template_attribute( - "_brightness", - self._level_template, - None, - self._update_brightness, - none_on_template_error=True, - ) - if self._max_mireds_template: - self.add_template_attribute( - "_max_mireds_template", - self._max_mireds_template, - None, - self._update_max_mireds, - none_on_template_error=True, - ) - if self._min_mireds_template: - self.add_template_attribute( - "_min_mireds_template", - self._min_mireds_template, - None, - self._update_min_mireds, - none_on_template_error=True, - ) - if self._temperature_template: - self.add_template_attribute( - "_temperature", - self._temperature_template, - None, - self._update_temperature, - none_on_template_error=True, - ) - if self._hs_template: - self.add_template_attribute( - "_hs_color", - self._hs_template, - None, - self._update_hs, - none_on_template_error=True, - ) - if self._rgb_template: - self.add_template_attribute( - "_rgb_color", - self._rgb_template, - None, - self._update_rgb, - none_on_template_error=True, - ) - if self._rgbw_template: - self.add_template_attribute( - "_rgbw_color", - self._rgbw_template, - None, - self._update_rgbw, - none_on_template_error=True, - ) - if self._rgbww_template: - self.add_template_attribute( - "_rgbww_color", - self._rgbww_template, - None, - self._update_rgbww, - none_on_template_error=True, - ) - if self._effect_list_template: - self.add_template_attribute( - "_effect_list", - self._effect_list_template, - None, - self._update_effect_list, - none_on_template_error=True, - ) - if self._effect_template: - self.add_template_attribute( - "_effect", - self._effect_template, - None, - self._update_effect, - none_on_template_error=True, - ) - if self._supports_transition_template: - self.add_template_attribute( - "_supports_transition_template", - self._supports_transition_template, - None, - self._update_supports_transition, - none_on_template_error=True, - ) - super()._async_setup_templates() + def set_optimistic_attributes(self, **kwargs) -> bool: # noqa: C901 + """Update attributes which should be set optimistically. - async def async_turn_on(self, **kwargs: Any) -> None: # noqa: C901 - """Turn the light on.""" + Returns True if any attribute was updated. + """ optimistic_set = False - # set optimistic states if self._template is None: self._state = True optimistic_set = True @@ -613,6 +508,10 @@ class LightTemplate(TemplateEntity, LightEntity): self._rgbw_color = None optimistic_set = True + return optimistic_set + + def get_registered_script(self, **kwargs) -> tuple[str, dict]: + """Get registered script for turn_on.""" common_params = {} if ATTR_BRIGHTNESS in kwargs: @@ -621,24 +520,23 @@ class LightTemplate(TemplateEntity, LightEntity): if ATTR_TRANSITION in kwargs and self._supports_transition is True: common_params["transition"] = kwargs[ATTR_TRANSITION] - if ATTR_COLOR_TEMP_KELVIN in kwargs and ( - temperature_script := self._action_scripts.get(CONF_TEMPERATURE_ACTION) + if ( + ATTR_COLOR_TEMP_KELVIN in kwargs + and (script := CONF_TEMPERATURE_ACTION) in self._registered_scripts ): common_params["color_temp"] = color_util.color_temperature_kelvin_to_mired( kwargs[ATTR_COLOR_TEMP_KELVIN] ) - await self.async_run_script( - temperature_script, - run_variables=common_params, - context=self._context, - ) - elif ATTR_EFFECT in kwargs and ( - effect_script := self._action_scripts.get(CONF_EFFECT_ACTION) + return (script, common_params) + + if ( + ATTR_EFFECT in kwargs + and (script := CONF_EFFECT_ACTION) in self._registered_scripts ): assert self._effect_list is not None effect = kwargs[ATTR_EFFECT] - if effect not in self._effect_list: + if self._effect_list is not None and effect not in self._effect_list: _LOGGER.error( "Received invalid effect: %s for entity %s. Expected one of: %s", effect, @@ -649,22 +547,22 @@ class LightTemplate(TemplateEntity, LightEntity): common_params["effect"] = effect - await self.async_run_script( - effect_script, run_variables=common_params, context=self._context - ) - elif ATTR_HS_COLOR in kwargs and ( - hs_script := self._action_scripts.get(CONF_HS_ACTION) + return (script, common_params) + + if ( + ATTR_HS_COLOR in kwargs + and (script := CONF_HS_ACTION) in self._registered_scripts ): hs_value = kwargs[ATTR_HS_COLOR] common_params["hs"] = hs_value common_params["h"] = int(hs_value[0]) common_params["s"] = int(hs_value[1]) - await self.async_run_script( - hs_script, run_variables=common_params, context=self._context - ) - elif ATTR_RGBWW_COLOR in kwargs and ( - rgbww_script := self._action_scripts.get(CONF_RGBWW_ACTION) + return (script, common_params) + + if ( + ATTR_RGBWW_COLOR in kwargs + and (script := CONF_RGBWW_ACTION) in self._registered_scripts ): rgbww_value = kwargs[ATTR_RGBWW_COLOR] common_params["rgbww"] = rgbww_value @@ -679,11 +577,11 @@ class LightTemplate(TemplateEntity, LightEntity): common_params["cw"] = int(rgbww_value[3]) common_params["ww"] = int(rgbww_value[4]) - await self.async_run_script( - rgbww_script, run_variables=common_params, context=self._context - ) - elif ATTR_RGBW_COLOR in kwargs and ( - rgbw_script := self._action_scripts.get(CONF_RGBW_ACTION) + return (script, common_params) + + if ( + ATTR_RGBW_COLOR in kwargs + and (script := CONF_RGBW_ACTION) in self._registered_scripts ): rgbw_value = kwargs[ATTR_RGBW_COLOR] common_params["rgbw"] = rgbw_value @@ -697,11 +595,11 @@ class LightTemplate(TemplateEntity, LightEntity): common_params["b"] = int(rgbw_value[2]) common_params["w"] = int(rgbw_value[3]) - await self.async_run_script( - rgbw_script, run_variables=common_params, context=self._context - ) - elif ATTR_RGB_COLOR in kwargs and ( - rgb_script := self._action_scripts.get(CONF_RGB_ACTION) + return (script, common_params) + + if ( + ATTR_RGB_COLOR in kwargs + and (script := CONF_RGB_ACTION) in self._registered_scripts ): rgb_value = kwargs[ATTR_RGB_COLOR] common_params["rgb"] = rgb_value @@ -709,39 +607,15 @@ class LightTemplate(TemplateEntity, LightEntity): common_params["g"] = int(rgb_value[1]) common_params["b"] = int(rgb_value[2]) - await self.async_run_script( - rgb_script, run_variables=common_params, context=self._context - ) - elif ATTR_BRIGHTNESS in kwargs and ( - level_script := self._action_scripts.get(CONF_LEVEL_ACTION) + return (script, common_params) + + if ( + ATTR_BRIGHTNESS in kwargs + and (script := CONF_LEVEL_ACTION) in self._registered_scripts ): - await self.async_run_script( - level_script, run_variables=common_params, context=self._context - ) - else: - await self.async_run_script( - self._action_scripts[CONF_ON_ACTION], - run_variables=common_params, - context=self._context, - ) + return (script, common_params) - if optimistic_set: - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the light off.""" - off_script = self._action_scripts[CONF_OFF_ACTION] - if ATTR_TRANSITION in kwargs and self._supports_transition is True: - await self.async_run_script( - off_script, - run_variables={"transition": kwargs[ATTR_TRANSITION]}, - context=self._context, - ) - else: - await self.async_run_script(off_script, context=self._context) - if self._template is None: - self._state = False - self.async_write_ha_state() + return (CONF_ON_ACTION, common_params) @callback def _update_brightness(self, brightness): @@ -809,33 +683,6 @@ class LightTemplate(TemplateEntity, LightEntity): self._effect = effect - @callback - def _update_state(self, result): - """Update the state from the template.""" - if isinstance(result, TemplateError): - # This behavior is legacy - self._state = False - if not self._availability_template: - self._attr_available = True - return - - if isinstance(result, bool): - self._state = result - return - - state = str(result).lower() - if state in _VALID_STATES: - self._state = state in ("true", STATE_ON) - return - - _LOGGER.error( - "Received invalid light is_on state: %s for entity %s. Expected: %s", - state, - self.entity_id, - ", ".join(_VALID_STATES), - ) - self._state = None - @callback def _update_temperature(self, render): """Update the temperature from the template.""" @@ -1092,3 +939,338 @@ class LightTemplate(TemplateEntity, LightEntity): self._supports_transition = bool(render) if self._supports_transition: self._attr_supported_features |= LightEntityFeature.TRANSITION + + +class LightTemplate(TemplateEntity, AbstractTemplateLight): + """Representation of a templated Light, including dimmable.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config: dict[str, Any], + unique_id: str | None, + ) -> None: + """Initialize the light.""" + TemplateEntity.__init__( + self, hass, config=config, fallback_name=None, unique_id=unique_id + ) + AbstractTemplateLight.__init__(self, config) + if (object_id := config.get(CONF_OBJECT_ID)) is not None: + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, object_id, hass=hass + ) + name = self._attr_name + if TYPE_CHECKING: + assert name is not None + + color_modes = {ColorMode.ONOFF} + for action_id, action_config, color_mode in self._register_scripts(config): + self.add_script(action_id, action_config, name, DOMAIN) + if color_mode: + color_modes.add(color_mode) + + self._supported_color_modes = filter_supported_color_modes(color_modes) + if len(self._supported_color_modes) > 1: + self._color_mode = ColorMode.UNKNOWN + if len(self._supported_color_modes) == 1: + self._color_mode = next(iter(self._supported_color_modes)) + + self._attr_supported_features = LightEntityFeature(0) + if self._action_scripts.get(CONF_EFFECT_ACTION): + self._attr_supported_features |= LightEntityFeature.EFFECT + if self._supports_transition is True: + self._attr_supported_features |= LightEntityFeature.TRANSITION + + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" + if self._template: + self.add_template_attribute( + "_state", self._template, None, self._update_state + ) + if self._level_template: + self.add_template_attribute( + "_brightness", + self._level_template, + None, + self._update_brightness, + none_on_template_error=True, + ) + if self._max_mireds_template: + self.add_template_attribute( + "_max_mireds_template", + self._max_mireds_template, + None, + self._update_max_mireds, + none_on_template_error=True, + ) + if self._min_mireds_template: + self.add_template_attribute( + "_min_mireds_template", + self._min_mireds_template, + None, + self._update_min_mireds, + none_on_template_error=True, + ) + if self._temperature_template: + self.add_template_attribute( + "_temperature", + self._temperature_template, + None, + self._update_temperature, + none_on_template_error=True, + ) + if self._hs_template: + self.add_template_attribute( + "_hs_color", + self._hs_template, + None, + self._update_hs, + none_on_template_error=True, + ) + if self._rgb_template: + self.add_template_attribute( + "_rgb_color", + self._rgb_template, + None, + self._update_rgb, + none_on_template_error=True, + ) + if self._rgbw_template: + self.add_template_attribute( + "_rgbw_color", + self._rgbw_template, + None, + self._update_rgbw, + none_on_template_error=True, + ) + if self._rgbww_template: + self.add_template_attribute( + "_rgbww_color", + self._rgbww_template, + None, + self._update_rgbww, + none_on_template_error=True, + ) + if self._effect_list_template: + self.add_template_attribute( + "_effect_list", + self._effect_list_template, + None, + self._update_effect_list, + none_on_template_error=True, + ) + if self._effect_template: + self.add_template_attribute( + "_effect", + self._effect_template, + None, + self._update_effect, + none_on_template_error=True, + ) + if self._supports_transition_template: + self.add_template_attribute( + "_supports_transition_template", + self._supports_transition_template, + None, + self._update_supports_transition, + none_on_template_error=True, + ) + super()._async_setup_templates() + + @callback + def _update_state(self, result): + """Update the state from the template.""" + if isinstance(result, TemplateError): + # This behavior is legacy + self._state = False + if not self._availability_template: + self._attr_available = True + return + + if isinstance(result, bool): + self._state = result + return + + state = str(result).lower() + if state in _VALID_STATES: + self._state = state in ("true", STATE_ON) + return + + _LOGGER.error( + "Received invalid light is_on state: %s for entity %s. Expected: %s", + state, + self.entity_id, + ", ".join(_VALID_STATES), + ) + self._state = None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + optimistic_set = self.set_optimistic_attributes(**kwargs) + script_id, script_params = self.get_registered_script(**kwargs) + await self.async_run_script( + self._action_scripts[script_id], + run_variables=script_params, + context=self._context, + ) + + if optimistic_set: + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + off_script = self._action_scripts[CONF_OFF_ACTION] + if ATTR_TRANSITION in kwargs and self._supports_transition is True: + await self.async_run_script( + off_script, + run_variables={"transition": kwargs[ATTR_TRANSITION]}, + context=self._context, + ) + else: + await self.async_run_script(off_script, context=self._context) + if self._template is None: + self._state = False + self.async_write_ha_state() + + +class TriggerLightEntity(TriggerEntity, AbstractTemplateLight): + """Light entity based on trigger data.""" + + domain = LIGHT_DOMAIN + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateLight.__init__(self, config, None) + + # Render the _attr_name before initializing TemplateLightEntity + self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + + self._optimistic_attrs: dict[str, str] = {} + self._optimistic = True + for key in ( + CONF_STATE, + CONF_LEVEL, + CONF_TEMPERATURE, + CONF_RGB, + CONF_RGBW, + CONF_RGBWW, + CONF_EFFECT, + CONF_MAX_MIREDS, + CONF_MIN_MIREDS, + CONF_SUPPORTS_TRANSITION, + ): + if isinstance(config.get(key), template.Template): + if key == CONF_STATE: + self._optimistic = False + self._to_render_simple.append(key) + self._parse_result.add(key) + + for key in (CONF_EFFECT_LIST, CONF_HS): + if isinstance(config.get(key), template.Template): + self._to_render_complex.append(key) + self._parse_result.add(key) + + color_modes = {ColorMode.ONOFF} + for action_id, action_config, color_mode in self._register_scripts(config): + self.add_script(action_id, action_config, name, DOMAIN) + if color_mode: + color_modes.add(color_mode) + + self._supported_color_modes = filter_supported_color_modes(color_modes) + if len(self._supported_color_modes) > 1: + self._color_mode = ColorMode.UNKNOWN + if len(self._supported_color_modes) == 1: + self._color_mode = next(iter(self._supported_color_modes)) + + self._attr_supported_features = LightEntityFeature(0) + if self._action_scripts.get(CONF_EFFECT_ACTION): + self._attr_supported_features |= LightEntityFeature.EFFECT + if self._supports_transition is True: + self._attr_supported_features |= LightEntityFeature.TRANSITION + + @callback + def _handle_coordinator_update(self) -> None: + """Handle update of the data.""" + self._process_data() + + if not self.available: + self.async_write_ha_state() + return + + write_ha_state = False + for key, updater in ( + (CONF_LEVEL, self._update_brightness), + (CONF_EFFECT_LIST, self._update_effect_list), + (CONF_EFFECT, self._update_effect), + (CONF_TEMPERATURE, self._update_temperature), + (CONF_HS, self._update_hs), + (CONF_RGB, self._update_rgb), + (CONF_RGBW, self._update_rgbw), + (CONF_RGBWW, self._update_rgbww), + (CONF_MAX_MIREDS, self._update_max_mireds), + (CONF_MIN_MIREDS, self._update_min_mireds), + ): + if (rendered := self._rendered.get(key)) is not None: + updater(rendered) + write_ha_state = True + + if (rendered := self._rendered.get(CONF_SUPPORTS_TRANSITION)) is not None: + self._update_supports_transition(rendered) + write_ha_state = True + + if not self._optimistic: + raw = self._rendered.get(CONF_STATE) + self._state = template.result_as_boolean(raw) + + self.async_set_context(self.coordinator.data["context"]) + write_ha_state = True + elif self._optimistic and len(self._rendered) > 0: + # In case any non optimistic template + write_ha_state = True + + if write_ha_state: + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + optimistic_set = self.set_optimistic_attributes(**kwargs) + script_id, script_params = self.get_registered_script(**kwargs) + if self._template and self._state is None: + # Ensure an optimistic state is set on the entity when turn_on + # is called and the main state hasn't rendered. This will only + # occur when the state is unknown, the template hasn't triggered, + # and turn_on is called. + self._state = True + + await self.async_run_script( + self._action_scripts[script_id], + run_variables=script_params, + context=self._context, + ) + + if optimistic_set: + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + off_script = self._action_scripts[CONF_OFF_ACTION] + if ATTR_TRANSITION in kwargs and self._supports_transition is True: + await self.async_run_script( + off_script, + run_variables={"transition": kwargs[ATTR_TRANSITION]}, + context=self._context, + ) + else: + await self.async_run_script(off_script, context=self._context) + if self._template is None: + self._state = False + self.async_write_ha_state() diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index ca3736ebf76..508c8b2aed4 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -33,6 +33,8 @@ from homeassistant.const import ( CONF_NAME, CONF_SENSORS, CONF_STATE, + CONF_TRIGGER, + CONF_TRIGGERS, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, @@ -53,12 +55,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator -from .const import ( - CONF_ATTRIBUTE_TEMPLATES, - CONF_AVAILABILITY_TEMPLATE, - CONF_OBJECT_ID, - CONF_TRIGGER, -) +from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID from .template_entity import ( TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity, @@ -132,7 +129,7 @@ LEGACY_SENSOR_SCHEMA = vol.All( def extra_validation_checks(val): """Run extra validation checks.""" - if CONF_TRIGGER in val: + if CONF_TRIGGERS in val or CONF_TRIGGER in val: raise vol.Invalid( "You can only add triggers to template entities if they are defined under" " `template:`. See the template documentation for more information:" @@ -170,6 +167,7 @@ PLATFORM_SCHEMA = vol.All( SENSOR_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_TRIGGER): cv.match_all, # to raise custom warning + vol.Optional(CONF_TRIGGERS): cv.match_all, # to raise custom warning vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(LEGACY_SENSOR_SCHEMA), } ), diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 66864a027ba..0b431d661cd 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -290,8 +290,10 @@ "options": { "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "area": "[%key:component::sensor::entity_component::area::name%]", "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", "battery": "[%key:component::sensor::entity_component::battery::name%]", + "blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]", "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", "conductivity": "[%key:component::sensor::entity_component::conductivity::name%]", @@ -302,6 +304,7 @@ "distance": "[%key:component::sensor::entity_component::distance::name%]", "duration": "[%key:component::sensor::entity_component::duration::name%]", "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]", "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", "frequency": "[%key:component::sensor::entity_component::frequency::name%]", "gas": "[%key:component::sensor::entity_component::gas::name%]", @@ -338,6 +341,7 @@ "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", "water": "[%key:component::sensor::entity_component::water::name%]", "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]", "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" } }, diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 1d18ea9d5ca..0f6d45f46ca 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA, SwitchEntity, @@ -23,6 +24,8 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError @@ -36,6 +39,7 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import TriggerUpdateCoordinator from .const import CONF_OBJECT_ID, CONF_PICTURE, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, @@ -45,6 +49,7 @@ from .template_entity import ( TemplateEntity, rewrite_common_legacy_to_modern_conf, ) +from .trigger_entity import TriggerEntity _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] @@ -173,6 +178,13 @@ async def async_setup_platform( ) return + if "coordinator" in discovery_info: + async_add_entities( + TriggerSwitchEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + _async_create_template_tracking_entities( async_add_entities, hass, @@ -295,3 +307,83 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): if self._template is None: self._state = False self.async_write_ha_state() + + +class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): + """Switch entity based on trigger data.""" + + domain = SWITCH_DOMAIN + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + super().__init__(hass, coordinator, config) + name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + self._template = config.get(CONF_STATE) + if on_action := config.get(CONF_TURN_ON): + self.add_script(CONF_TURN_ON, on_action, name, DOMAIN) + if off_action := config.get(CONF_TURN_OFF): + self.add_script(CONF_TURN_OFF, off_action, name, DOMAIN) + + self._attr_assumed_state = self._template is None + if not self._attr_assumed_state: + self._to_render_simple.append(CONF_STATE) + self._parse_result.add(CONF_STATE) + + self._attr_device_info = async_device_info_to_link_from_device_id( + hass, + config.get(CONF_DEVICE_ID), + ) + + async def async_added_to_hass(self) -> None: + """Restore last state.""" + await super().async_added_to_hass() + if ( + (last_state := await self.async_get_last_state()) is not None + and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + # The trigger might have fired already while we waited for stored data, + # then we should not restore state + and self.is_on is None + ): + self._attr_is_on = last_state.state == STATE_ON + self.restore_attributes(last_state) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle update of the data.""" + self._process_data() + + if not self.available: + self.async_write_ha_state() + return + + if not self._attr_assumed_state: + raw = self._rendered.get(CONF_STATE) + self._attr_is_on = template.result_as_boolean(raw) + + self.async_set_context(self.coordinator.data["context"]) + self.async_write_ha_state() + elif self._attr_assumed_state and len(self._rendered) > 0: + # In case name, icon, or friendly name have a template but + # states does not + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Fire the on action.""" + if on_script := self._action_scripts.get(CONF_TURN_ON): + await self.async_run_script(on_script, context=self._context) + if self._template is None: + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Fire the off action.""" + if off_script := self._action_scripts.get(CONF_TURN_OFF): + await self.async_run_script(off_script, context=self._context) + if self._template is None: + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 87c93b6143b..4565e86843a 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -2,8 +2,11 @@ from __future__ import annotations +from typing import Any + +from homeassistant.const import CONF_STATE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.template import TemplateStateFromEntityId +from homeassistant.helpers.template import _SENTINEL from homeassistant.helpers.trigger_template_entity import TriggerBaseEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -29,6 +32,8 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module TriggerBaseEntity.__init__(self, hass, config) AbstractTemplateEntity.__init__(self, hass) + self._state_render_error = False + async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" await super().async_added_to_hass() @@ -47,22 +52,49 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module """Return referenced blueprint or None.""" return self.coordinator.referenced_blueprint + @property + def available(self) -> bool: + """Return availability of the entity.""" + if self._state_render_error: + return False + + return super().available + @callback def _render_script_variables(self) -> dict: """Render configured variables.""" - return self.coordinator.data["run_variables"] + if self.coordinator.data is None: + return {} + return self.coordinator.data["run_variables"] or {} + + def _render_templates(self, variables: dict[str, Any]) -> None: + """Render templates.""" + self._state_render_error = False + rendered = dict(self._static_rendered) + + # If state fails to render, the entity should go unavailable. Render the + # state as a simple template because the result should always be a string or None. + if CONF_STATE in self._to_render_simple: + if ( + result := self._render_single_template(CONF_STATE, variables) + ) is _SENTINEL: + self._rendered = self._static_rendered + self._state_render_error = True + return + + rendered[CONF_STATE] = result + + self._render_single_templates(rendered, variables, [CONF_STATE]) + self._render_attributes(rendered, variables) + self._rendered = rendered @callback def _process_data(self) -> None: """Process new data.""" - run_variables = self.coordinator.data["run_variables"] - variables = { - "this": TemplateStateFromEntityId(self.hass, self.entity_id), - **(run_variables or {}), - } - - self._render_templates(variables) + variables = self._template_variables(self.coordinator.data["run_variables"]) + if self._render_availability_template(variables): + self._render_templates(variables) self.async_set_context(self.coordinator.data["context"]) diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 81705e326f7..11e1b1d3485 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -11,6 +11,6 @@ "tf-models-official==2.5.0", "pycocotools==2.0.6", "numpy==2.2.2", - "Pillow==11.1.0" + "Pillow==11.2.1" ] } diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 331885893fe..04ccbd13b44 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -141,7 +141,7 @@ "state_attributes": { "preset_mode": { "state": { - "off": "Normal", + "off": "[%key:common::state::normal%]", "keep": "Keep mode", "dog": "Dog mode", "camp": "Camp mode" @@ -199,79 +199,79 @@ "name": "Charge limit" }, "off_grid_vehicle_charging_reserve_percent": { - "name": "Off grid reserve" + "name": "Off-grid reserve" } }, "select": { "climate_state_seat_heater_left": { "name": "Seat heater front left", "state": { - "high": "High", - "low": "Low", - "medium": "Medium", - "off": "Off" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_center": { "name": "Seat heater rear center", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_left": { "name": "Seat heater rear left", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_right": { "name": "Seat heater rear right", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_right": { "name": "Seat heater front right", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_left": { "name": "Seat heater third row left", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_right": { "name": "Seat heater third row right", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_steering_wheel_heat_level": { "name": "Steering wheel heater", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "off": "[%key:common::state::off%]" } }, "components_customer_preferred_export_rule": { @@ -287,7 +287,7 @@ "state": { "autonomous": "Autonomous", "backup": "Backup", - "self_consumption": "Self consumption" + "self_consumption": "Self-consumption" } } }, @@ -330,8 +330,8 @@ "state": { "starting": "Starting", "charging": "[%key:common::state::charging%]", - "disconnected": "Disconnected", - "stopped": "Stopped", + "disconnected": "[%key:common::state::disconnected%]", + "stopped": "[%key:common::state::stopped%]", "complete": "Complete", "no_power": "No power" } @@ -418,8 +418,8 @@ "name": "Grid Status", "state": { "island_status_unknown": "Unknown", - "on_grid": "Connected", - "off_grid": "Disconnected", + "on_grid": "[%key:common::state::connected%]", + "off_grid": "[%key:common::state::disconnected%]", "off_grid_unintentional": "Disconnected unintentionally", "off_grid_intentional": "Disconnected intentionally" } diff --git a/homeassistant/components/tesla_fleet/switch.py b/homeassistant/components/tesla_fleet/switch.py index 614af8772cc..4c64acfafa6 100644 --- a/homeassistant/components/tesla_fleet/switch.py +++ b/homeassistant/components/tesla_fleet/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api.const import Scope, Seat +from tesla_fleet_api.const import AutoSeat, Scope, Seat from homeassistant.components.switch import ( SwitchDeviceClass, @@ -46,7 +46,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( ), TeslaFleetSwitchEntityDescription( key="climate_state_auto_seat_climate_left", - on_func=lambda api: api.remote_auto_seat_climate_request(Seat.FRONT_LEFT, True), + on_func=lambda api: api.remote_auto_seat_climate_request( + AutoSeat.FRONT_LEFT, True + ), off_func=lambda api: api.remote_auto_seat_climate_request( Seat.FRONT_LEFT, False ), @@ -55,10 +57,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( TeslaFleetSwitchEntityDescription( key="climate_state_auto_seat_climate_right", on_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_RIGHT, True + AutoSeat.FRONT_RIGHT, True ), off_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_RIGHT, False + AutoSeat.FRONT_RIGHT, False ), scopes=[Scope.VEHICLE_CMDS], ), diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index b356a9f3ebc..f1247ea8f9f 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -38,7 +38,7 @@ "connected": "Vehicle connected", "ready": "Ready to charge", "negotiating": "Negotiating connection", - "error": "Error", + "error": "[%key:common::state::error%]", "charging_finished": "Charging finished", "waiting_car": "Waiting for car", "charging_reduced": "Charging (reduced)", diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index b820d2d1b43..9efa55de54f 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -100,6 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - access_token, server=f"{region.lower()}.teslemetry.com", parse_timestamp=True, + manual=True, ) for product in products: @@ -128,12 +129,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ) firmware = vehicle_metadata[vin].get("firmware", "Unknown") stream_vehicle = stream.get_vehicle(vin) + poll = product["command_signing"] == "off" vehicles.append( TeslemetryVehicleData( api=api, config_entry=entry, coordinator=coordinator, + poll=poll, stream=stream, stream_vehicle=stream_vehicle, vin=vin, @@ -202,6 +205,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - *( vehicle.coordinator.async_config_entry_first_refresh() for vehicle in vehicles + if vehicle.poll ), *( energysite.info_coordinator.async_config_entry_first_refresh() @@ -236,6 +240,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - entry.runtime_data = TeslemetryData(vehicles, energysites, scopes) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_create_background_task(hass, stream.listen(), "Teslemetry Stream") + return True diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 9d14df4501b..a62dbe1e00f 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -6,8 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import cast -from teslemetry_stream import Signal -from teslemetry_stream.const import WindowState +from teslemetry_stream.vehicle import TeslemetryStreamVehicle from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -32,6 +31,12 @@ from .models import TeslemetryEnergyData, TeslemetryVehicleData PARALLEL_UPDATES = 0 +WINDOW_STATES = { + "Opened": True, + "PartiallyOpen": True, + "Closed": False, +} + @dataclass(frozen=True, kw_only=True) class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): @@ -39,11 +44,14 @@ class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): polling_value_fn: Callable[[StateType], bool | None] = bool polling: bool = False - streaming_key: Signal | None = None + streaming_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[bool | None], None]], + Callable[[], None], + ] + | 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, ...] = ( @@ -51,12 +59,25 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( key="state", polling=True, polling_value_fn=lambda x: x == TeslemetryState.ONLINE, + streaming_listener=lambda x, y: x.listen_State(y), device_class=BinarySensorDeviceClass.CONNECTIVITY, ), + TeslemetryBinarySensorEntityDescription( + key="cellular", + streaming_listener=lambda x, y: x.listen_Cellular(y), + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetryBinarySensorEntityDescription( + key="wifi", + streaming_listener=lambda x, y: x.listen_Wifi(y), + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + ), TeslemetryBinarySensorEntityDescription( key="charge_state_battery_heater_on", polling=True, - streaming_key=Signal.BATTERY_HEATER_ON, + streaming_listener=lambda x, y: x.listen_BatteryHeaterOn(y), device_class=BinarySensorDeviceClass.HEAT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -64,15 +85,16 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="charge_state_charger_phases", polling=True, - streaming_key=Signal.CHARGER_PHASES, + streaming_listener=lambda x, y: x.listen_ChargerPhases( + lambda z: y(None if z is None else z > 1) + ), 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, + streaming_listener=lambda x, y: x.listen_PreconditioningEnabled(y), entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -85,7 +107,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="charge_state_scheduled_charging_pending", polling=True, - streaming_key=Signal.SCHEDULED_CHARGING_PENDING, + streaming_listener=lambda x, y: x.listen_ScheduledChargingPending(y), entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -153,32 +175,36 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="vehicle_state_fd_window", polling=True, - streaming_key=Signal.FD_WINDOW, - streaming_value_fn=lambda x: WindowState.get(x) != "Closed", + streaming_listener=lambda x, y: x.listen_FrontDriverWindow( + lambda z: y(WINDOW_STATES.get(z)) + ), 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", + streaming_listener=lambda x, y: x.listen_FrontPassengerWindow( + lambda z: y(WINDOW_STATES.get(z)) + ), 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", + streaming_listener=lambda x, y: x.listen_RearDriverWindow( + lambda z: y(WINDOW_STATES.get(z)) + ), 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", + streaming_listener=lambda x, y: x.listen_RearPassengerWindow( + lambda z: y(WINDOW_STATES.get(z)) + ), device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -186,190 +212,243 @@ VEHICLE_DESCRIPTIONS: tuple[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"), + streaming_listener=lambda x, y: x.listen_FrontDriverDoor(y), 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"), + streaming_listener=lambda x, y: x.listen_RearDriverDoor(y), 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"), + streaming_listener=lambda x, y: x.listen_FrontPassengerDoor(y), 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"), + streaming_listener=lambda x, y: x.listen_RearPassengerDoor(y), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="automatic_blind_spot_camera", - streaming_key=Signal.AUTOMATIC_BLIND_SPOT_CAMERA, + streaming_listener=lambda x, y: x.listen_AutomaticBlindSpotCamera(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="automatic_emergency_braking_off", - streaming_key=Signal.AUTOMATIC_EMERGENCY_BRAKING_OFF, + streaming_listener=lambda x, y: x.listen_AutomaticEmergencyBrakingOff(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="blind_spot_collision_warning_chime", - streaming_key=Signal.BLIND_SPOT_COLLISION_WARNING_CHIME, + streaming_listener=lambda x, y: x.listen_BlindSpotCollisionWarningChime(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="bms_full_charge_complete", - streaming_key=Signal.BMS_FULL_CHARGE_COMPLETE, + streaming_listener=lambda x, y: x.listen_BmsFullchargecomplete(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="brake_pedal", - streaming_key=Signal.BRAKE_PEDAL, + streaming_listener=lambda x, y: x.listen_BrakePedal(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_port_cold_weather_mode", - streaming_key=Signal.CHARGE_PORT_COLD_WEATHER_MODE, + streaming_listener=lambda x, y: x.listen_ChargePortColdWeatherMode(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="service_mode", - streaming_key=Signal.SERVICE_MODE, + streaming_listener=lambda x, y: x.listen_ServiceMode(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="pin_to_drive_enabled", - streaming_key=Signal.PIN_TO_DRIVE_ENABLED, + streaming_listener=lambda x, y: x.listen_PinToDriveEnabled(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="drive_rail", - streaming_key=Signal.DRIVE_RAIL, + streaming_listener=lambda x, y: x.listen_DriveRail(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="driver_seat_belt", - streaming_key=Signal.DRIVER_SEAT_BELT, + streaming_listener=lambda x, y: x.listen_DriverSeatBelt(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="driver_seat_occupied", - streaming_key=Signal.DRIVER_SEAT_OCCUPIED, + streaming_listener=lambda x, y: x.listen_DriverSeatOccupied(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="passenger_seat_belt", - streaming_key=Signal.PASSENGER_SEAT_BELT, + streaming_listener=lambda x, y: x.listen_PassengerSeatBelt(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="fast_charger_present", - streaming_key=Signal.FAST_CHARGER_PRESENT, + streaming_listener=lambda x, y: x.listen_FastChargerPresent(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="gps_state", - streaming_key=Signal.GPS_STATE, + streaming_listener=lambda x, y: x.listen_GpsState(y), entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, ), TeslemetryBinarySensorEntityDescription( key="guest_mode_enabled", - streaming_key=Signal.GUEST_MODE_ENABLED, + streaming_listener=lambda x, y: x.listen_GuestModeEnabled(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="dc_dc_enable", - streaming_key=Signal.DC_DC_ENABLE, + streaming_listener=lambda x, y: x.listen_DCDCEnable(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="emergency_lane_departure_avoidance", - streaming_key=Signal.EMERGENCY_LANE_DEPARTURE_AVOIDANCE, + streaming_listener=lambda x, y: x.listen_EmergencyLaneDepartureAvoidance(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="supercharger_session_trip_planner", - streaming_key=Signal.SUPERCHARGER_SESSION_TRIP_PLANNER, + streaming_listener=lambda x, y: x.listen_SuperchargerSessionTripPlanner(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="wiper_heat_enabled", - streaming_key=Signal.WIPER_HEAT_ENABLED, + streaming_listener=lambda x, y: x.listen_WiperHeatEnabled(y), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="rear_display_hvac_enabled", - streaming_key=Signal.REAR_DISPLAY_HVAC_ENABLED, + streaming_listener=lambda x, y: x.listen_RearDisplayHvacEnabled(y), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="offroad_lightbar_present", - streaming_key=Signal.OFFROAD_LIGHTBAR_PRESENT, + streaming_listener=lambda x, y: x.listen_OffroadLightbarPresent(y), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="homelink_nearby", - streaming_key=Signal.HOMELINK_NEARBY, + streaming_listener=lambda x, y: x.listen_HomelinkNearby(y), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="europe_vehicle", - streaming_key=Signal.EUROPE_VEHICLE, + streaming_listener=lambda x, y: x.listen_EuropeVehicle(y), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="right_hand_drive", - streaming_key=Signal.RIGHT_HAND_DRIVE, + streaming_listener=lambda x, y: x.listen_RightHandDrive(y), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="located_at_home", - streaming_key=Signal.LOCATED_AT_HOME, + streaming_listener=lambda x, y: x.listen_LocatedAtHome(y), streaming_firmware="2024.44.32", ), TeslemetryBinarySensorEntityDescription( key="located_at_work", - streaming_key=Signal.LOCATED_AT_WORK, + streaming_listener=lambda x, y: x.listen_LocatedAtWork(y), streaming_firmware="2024.44.32", ), TeslemetryBinarySensorEntityDescription( key="located_at_favorite", - streaming_key=Signal.LOCATED_AT_FAVORITE, + streaming_listener=lambda x, y: x.listen_LocatedAtFavorite(y), streaming_firmware="2024.44.32", entity_registry_enabled_default=False, ), + TeslemetryBinarySensorEntityDescription( + key="charge_enable_request", + streaming_listener=lambda x, y: x.listen_ChargeEnableRequest(y), + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="defrost_for_preconditioning", + streaming_listener=lambda x, y: x.listen_DefrostForPreconditioning(y), + entity_registry_enabled_default=False, + streaming_firmware="2024.44.25", + ), + TeslemetryBinarySensorEntityDescription( + key="lights_high_beams", + streaming_listener=lambda x, y: x.listen_LightsHighBeams(y), + entity_registry_enabled_default=False, + streaming_firmware="2025.2.6", + ), + TeslemetryBinarySensorEntityDescription( + key="seat_vent_enabled", + streaming_listener=lambda x, y: x.listen_SeatVentEnabled(y), + entity_registry_enabled_default=False, + streaming_firmware="2025.2.6", + ), + TeslemetryBinarySensorEntityDescription( + key="speed_limit_mode", + streaming_listener=lambda x, y: x.listen_SpeedLimitMode(y), + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="remote_start_enabled", + streaming_listener=lambda x, y: x.listen_RemoteStartEnabled(y), + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="hvil", + streaming_listener=lambda x, y: x.listen_Hvil(lambda z: y(z == "Fault")), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetryBinarySensorEntityDescription( + key="hvac_auto_mode", + streaming_listener=lambda x, y: x.listen_HvacAutoMode(lambda z: y(z == "On")), + entity_registry_enabled_default=False, + ), ) -ENERGY_LIVE_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( - BinarySensorEntityDescription(key="backup_capable"), - BinarySensorEntityDescription(key="grid_services_active"), - BinarySensorEntityDescription(key="storm_mode_active"), + +ENERGY_LIVE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( + TeslemetryBinarySensorEntityDescription( + key="grid_status", + polling_value_fn=lambda x: x == "Active", + device_class=BinarySensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetryBinarySensorEntityDescription( + key="backup_capable", entity_category=EntityCategory.DIAGNOSTIC + ), + TeslemetryBinarySensorEntityDescription( + key="grid_services_active", entity_category=EntityCategory.DIAGNOSTIC + ), + TeslemetryBinarySensorEntityDescription(key="storm_mode_active"), ) -ENERGY_INFO_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( - BinarySensorEntityDescription( +ENERGY_INFO_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( + TeslemetryBinarySensorEntityDescription( key="components_grid_services_enabled", + entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -386,7 +465,7 @@ async def async_setup_entry( for description in VEHICLE_DESCRIPTIONS: if ( not vehicle.api.pre2021 - and description.streaming_key + and description.streaming_listener and vehicle.firmware >= description.streaming_firmware ): entities.append( @@ -453,8 +532,7 @@ class TeslemetryVehicleStreamingBinarySensorEntity( ) -> None: """Initialize the sensor.""" self.entity_description = description - assert description.streaming_key - super().__init__(data, description.key, description.streaming_key) + super().__init__(data, description.key) async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -462,11 +540,18 @@ class TeslemetryVehicleStreamingBinarySensorEntity( 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: + assert self.entity_description.streaming_listener + self.async_on_remove( + self.entity_description.streaming_listener( + self.vehicle.stream_vehicle, self._async_value_from_stream + ) + ) + + def _async_value_from_stream(self, value: bool | None) -> 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) + self._attr_is_on = value + self.async_write_ha_state() class TeslemetryEnergyLiveBinarySensorEntity( @@ -474,12 +559,12 @@ class TeslemetryEnergyLiveBinarySensorEntity( ): """Base class for Teslemetry energy live binary sensors.""" - entity_description: BinarySensorEntityDescription + entity_description: TeslemetryBinarySensorEntityDescription def __init__( self, data: TeslemetryEnergyData, - description: BinarySensorEntityDescription, + description: TeslemetryBinarySensorEntityDescription, ) -> None: """Initialize the binary sensor.""" self.entity_description = description @@ -487,7 +572,7 @@ class TeslemetryEnergyLiveBinarySensorEntity( def _async_update_attrs(self) -> None: """Update the attributes of the binary sensor.""" - self._attr_is_on = self._value + self._attr_is_on = self.entity_description.polling_value_fn(self._value) class TeslemetryEnergyInfoBinarySensorEntity( @@ -495,12 +580,12 @@ class TeslemetryEnergyInfoBinarySensorEntity( ): """Base class for Teslemetry energy info binary sensors.""" - entity_description: BinarySensorEntityDescription + entity_description: TeslemetryBinarySensorEntityDescription def __init__( self, data: TeslemetryEnergyData, - description: BinarySensorEntityDescription, + description: TeslemetryBinarySensorEntityDescription, ) -> None: """Initialize the binary sensor.""" self.entity_description = description @@ -508,4 +593,4 @@ class TeslemetryEnergyInfoBinarySensorEntity( def _async_update_attrs(self) -> None: """Update the attributes of the binary sensor.""" - self._attr_is_on = self._value + self._attr_is_on = self.entity_description.polling_value_fn(self._value) diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 07549008a6c..406b9cb2d84 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -58,8 +58,11 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): LOGGER, config_entry=config_entry, name="Teslemetry Vehicle", - update_interval=VEHICLE_INTERVAL, ) + if product["command_signing"] == "off": + # Only allow automatic polling if its included + self.update_interval = VEHICLE_INTERVAL + self.api = api self.data = flatten(product) self.last_active = datetime.now() diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index de91f43f084..cde1d3f7d4f 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -175,7 +175,7 @@ class TeslemetryStreamingWindowEntity( self.async_on_remove( self.stream.async_add_listener( self._handle_stream_update, - {"vin": self.vin, "data": {self.streaming_key: None}}, + {"vin": self.vin, "data": None}, ) ) for signal in ( diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index 6a758e68497..bb90a7b19bd 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from tesla_fleet_api.const import Scope from teslemetry_stream import TeslemetryStreamVehicle from teslemetry_stream.const import TeslaLocation @@ -75,6 +76,10 @@ async def async_setup_entry( entities: list[ TeslemetryPollingDeviceTrackerEntity | TeslemetryStreamingDeviceTrackerEntity ] = [] + # Only add vehicle location entities if the user has granted vehicle location scope. + if Scope.VEHICLE_LOCATION not in entry.runtime_data.scopes: + return + for vehicle in entry.runtime_data.vehicles: for description in DESCRIPTIONS: if vehicle.api.pre2021 or vehicle.firmware < description.streaming_firmware: diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index 3d145d24b0c..9ce812980db 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -3,10 +3,8 @@ from abc import abstractmethod from typing import Any -from propcache.api import cached_property from tesla_fleet_api.const import Scope from tesla_fleet_api.teslemetry import EnergySite, Vehicle -from teslemetry_stream import Signal from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo @@ -20,7 +18,6 @@ from .coordinator import ( TeslemetryEnergySiteLiveCoordinator, TeslemetryVehicleDataCoordinator, ) -from .helpers import wake_up_vehicle from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -119,6 +116,12 @@ class TeslemetryVehicleEntity(TeslemetryEntity): self.vehicle = data self._attr_unique_id = f"{data.vin}-{key}" self._attr_device_info = data.device + + if not data.poll: + # This entities data is not available for free + # so disable it by default + self._attr_entity_registry_enabled_default = False + super().__init__(data.coordinator, key) @property @@ -126,10 +129,6 @@ class TeslemetryVehicleEntity(TeslemetryEntity): """Return a specific value from coordinator data.""" return self.coordinator.data.get(self.key) - async def wake_up_if_asleep(self) -> None: - """Wake up the vehicle if its asleep.""" - await wake_up_vehicle(self.vehicle) - class TeslemetryEnergyLiveEntity(TeslemetryEntity): """Parent class for Teslemetry Energy Site Live entities.""" @@ -249,11 +248,8 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity): class TeslemetryVehicleStreamEntity(TeslemetryRootEntity): """Parent class for Teslemetry Vehicle Stream entities.""" - def __init__( - self, data: TeslemetryVehicleData, key: str, streaming_key: Signal | None = None - ) -> None: + def __init__(self, data: TeslemetryVehicleData, key: str) -> None: """Initialize common aspects of a Teslemetry entity.""" - self.streaming_key = streaming_key self.vehicle = data self.api = data.api @@ -265,32 +261,7 @@ class TeslemetryVehicleStreamEntity(TeslemetryRootEntity): 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 + @property def available(self) -> bool: """Return True if entity is available.""" return self.stream.connected diff --git a/homeassistant/components/teslemetry/helpers.py b/homeassistant/components/teslemetry/helpers.py index 30601feccbc..c6f15d7bfdf 100644 --- a/homeassistant/components/teslemetry/helpers.py +++ b/homeassistant/components/teslemetry/helpers.py @@ -1,13 +1,12 @@ """Teslemetry helper functions.""" -import asyncio from typing import Any from tesla_fleet_api.exceptions import TeslaFleetError from homeassistant.exceptions import HomeAssistantError -from .const import DOMAIN, LOGGER, TeslemetryState +from .const import DOMAIN, LOGGER def flatten(data: dict[str, Any], parent: str | None = None) -> dict[str, Any]: @@ -23,34 +22,6 @@ def flatten(data: dict[str, Any], parent: str | None = None) -> dict[str, Any]: return result -async def wake_up_vehicle(vehicle) -> None: - """Wake up a vehicle.""" - async with vehicle.wakelock: - times = 0 - while vehicle.coordinator.data["state"] != TeslemetryState.ONLINE: - try: - if times == 0: - cmd = await vehicle.api.wake_up() - else: - cmd = await vehicle.api.vehicle() - state = cmd["response"]["state"] - except TeslaFleetError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="wake_up_failed", - translation_placeholders={"message": e.message}, - ) from e - vehicle.coordinator.data["state"] = state - if state != TeslemetryState.ONLINE: - times += 1 - if times >= 4: # Give up after 30 seconds total - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="wake_up_timeout", - ) - await asyncio.sleep(times * 5) - - async def handle_command(command) -> dict[str, Any]: """Handle a command.""" try: diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index 9996a508177..06ac1595a80 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -1,6 +1,24 @@ { "entity": { "binary_sensor": { + "state": { + "state": { + "off": "mdi:sleep", + "on": "mdi:car-connected" + } + }, + "cellular": { + "state": { + "off": "mdi:signal-cellular-outline", + "on": "mdi:signal-cellular-3" + } + }, + "wifi": { + "state": { + "off": "mdi:wifi-off", + "on": "mdi:wifi" + } + }, "climate_state_is_preconditioning": { "state": { "off": "mdi:hvac-off", @@ -42,6 +60,72 @@ "off": "mdi:tire", "on": "mdi:car-tire-alert" } + }, + "charge_enable_request": { + "state": { + "off": "mdi:battery-off-outline", + "on": "mdi:battery-charging-outline" + } + }, + "defrost_for_preconditioning": { + "state": { + "off": "mdi:snowflake-off", + "on": "mdi:snowflake-melt" + } + }, + "lights_high_beams": { + "state": { + "off": "mdi:car-light-dimmed", + "on": "mdi:car-light-high" + } + }, + "seat_vent_enabled": { + "state": { + "off": "mdi:car-seat", + "on": "mdi:fan" + } + }, + "speed_limit_mode": { + "state": { + "off": "mdi:speedometer", + "on": "mdi:car-speed-limiter" + } + }, + "remote_start_enabled": { + "state": { + "off": "mdi:remote-off", + "on": "mdi:remote" + } + }, + "hvac_auto_mode": { + "state": { + "off": "mdi:hvac-off", + "on": "mdi:hvac" + } + }, + "backup_capable": { + "state": { + "off": "mdi:battery-off", + "on": "mdi:home-battery" + } + }, + "grid_status": { + "state": { + "off": "mdi:transmission-tower-off", + "on": "mdi:transmission-tower" + } + }, + "grid_services_active": { + "state": { + "on": "mdi:sine-wave", + "off": "mdi:transmission-tower-off" + } + }, + "components_grid_services_enabled": { + "state": { + "on": "mdi:sine-wave", + "off": "mdi:transmission-tower-off" + } } }, "button": { @@ -165,6 +249,7 @@ "default": "mdi:ev-plug-ccs2" } }, + "sensor": { "battery_power": { "default": "mdi:home-battery" @@ -288,6 +373,335 @@ }, "consumer_energy_imported_from_generator": { "default": "mdi:generator-stationary" + }, + "sentry_mode": { + "default": "mdi:shield-car", + "state": { + "off": "mdi:shield-off-outline", + "idle": "mdi:shield-outline", + "armed": "mdi:shield-check", + "aware": "mdi:shield-alert", + "panic": "mdi:shield-alert-outline", + "quiet": "mdi:shield-half-full" + } + }, + "bms_state": { + "default": "mdi:battery-heart-variant", + "state": { + "standby": "mdi:battery-clock", + "drive": "mdi:car-electric", + "support": "mdi:battery-check", + "charge": "mdi:battery-charging", + "full_electric_in_motion": "mdi:battery-arrow-up", + "clear_fault": "mdi:battery-alert-variant-outline", + "fault": "mdi:battery-alert", + "weld": "mdi:battery-lock", + "test": "mdi:battery-sync", + "system_not_available": "mdi:battery-off" + } + }, + "brake_pedal_position": { + "default": "mdi:car-brake-alert" + }, + "brick_voltage_max": { + "default": "mdi:battery-high" + }, + "brick_voltage_min": { + "default": "mdi:battery-low" + }, + "cruise_follow_distance": { + "default": "mdi:car-cruise-control" + }, + "cruise_set_speed": { + "default": "mdi:speedometer" + }, + "current_limit_mph": { + "default": "mdi:car-cruise-control" + }, + "dc_charging_energy_in": { + "default": "mdi:ev-station" + }, + "dc_charging_power": { + "default": "mdi:lightning-bolt" + }, + "di_axle_speed_f": { + "default": "mdi:speedometer" + }, + "di_axle_speed_r": { + "default": "mdi:speedometer" + }, + "di_axle_speed_rel": { + "default": "mdi:speedometer" + }, + "di_axle_speed_rer": { + "default": "mdi:speedometer" + }, + "di_heatsink_tf": { + "default": "mdi:thermometer" + }, + "di_heatsink_tr": { + "default": "mdi:thermometer" + }, + "di_heatsink_trel": { + "default": "mdi:thermometer" + }, + "di_heatsink_trer": { + "default": "mdi:thermometer" + }, + "di_inverter_tf": { + "default": "mdi:sine-wave" + }, + "di_inverter_tr": { + "default": "mdi:sine-wave" + }, + "di_inverter_trel": { + "default": "mdi:sine-wave" + }, + "di_inverter_trer": { + "default": "mdi:sine-wave" + }, + "di_motor_current_f": { + "default": "mdi:current-ac" + }, + "di_motor_current_r": { + "default": "mdi:current-ac" + }, + "di_motor_current_rel": { + "default": "mdi:current-ac" + }, + "di_motor_current_rer": { + "default": "mdi:current-ac" + }, + "di_slave_torque_cmd": { + "default": "mdi:engine" + }, + "di_state_f": { + "default": "mdi:car-electric", + "state": { + "unavailable": "mdi:car-off", + "standby": "mdi:power-sleep", + "fault": "mdi:alert-circle", + "abort": "mdi:stop-circle", + "enabled": "mdi:check-circle" + } + }, + "di_state_r": { + "default": "mdi:car-electric", + "state": { + "unavailable": "mdi:car-off", + "standby": "mdi:power-sleep", + "fault": "mdi:alert-circle", + "abort": "mdi:stop-circle", + "enabled": "mdi:check-circle" + } + }, + "di_state_rel": { + "default": "mdi:car-electric", + "state": { + "unavailable": "mdi:car-off", + "standby": "mdi:power-sleep", + "fault": "mdi:alert-circle", + "abort": "mdi:stop-circle", + "enabled": "mdi:check-circle" + } + }, + "di_state_rer": { + "default": "mdi:car-electric", + "state": { + "unavailable": "mdi:car-off", + "standby": "mdi:power-sleep", + "fault": "mdi:alert-circle", + "abort": "mdi:stop-circle", + "enabled": "mdi:check-circle" + } + }, + "di_stator_temp_f": { + "default": "mdi:thermometer" + }, + "di_stator_temp_r": { + "default": "mdi:thermometer" + }, + "di_stator_temp_rel": { + "default": "mdi:thermometer" + }, + "di_stator_temp_rer": { + "default": "mdi:thermometer" + }, + "energy_remaining": { + "default": "mdi:battery-medium" + }, + "estimated_hours_to_charge_termination": { + "default": "mdi:battery-clock" + }, + "forward_collision_warning": { + "default": "mdi:car-crash", + "state": { + "off": "mdi:car-off", + "late": "mdi:alert", + "average": "mdi:alert-circle", + "early": "mdi:alert-octagon" + } + }, + "gps_heading": { + "default": "mdi:compass" + }, + "guest_mode_mobile_access_state": { + "default": "mdi:account-key", + "state": { + "init": "mdi:cog-refresh", + "not_authenticated": "mdi:account-off", + "authenticated": "mdi:account-check", + "aborted_driving": "mdi:car-off", + "aborted_using_remote_start": "mdi:remote-off", + "aborted_using_ble_keys": "mdi:bluetooth-off", + "aborted_valet_mode": "mdi:car-key", + "aborted_guest_mode_off": "mdi:power-off", + "aborted_drive_auth_time_exceeded": "mdi:timer-off", + "aborted_no_data_received": "mdi:network-off", + "requesting_from_mothership": "mdi:cloud-download", + "requesting_from_auth_d": "mdi:shield-key", + "aborted_fetch_failed": "mdi:wifi-off", + "aborted_bad_data_received": "mdi:file-alert", + "showing_qr_code": "mdi:qrcode", + "swiped_away": "mdi:gesture-swipe", + "dismissed_qr_code_expired": "mdi:clock-alert", + "succeeded_paired_new_ble_key": "mdi:bluetooth-connect" + } + }, + "homelink_device_count": { + "default": "mdi:garage" + }, + "hvac_fan_speed": { + "default": "mdi:fan" + }, + "hvac_fan_status": { + "default": "mdi:fan" + }, + "isolation_resistance": { + "default": "mdi:resistor" + }, + "lane_departure_avoidance": { + "default": "mdi:road-variant", + "state": { + "warning": "mdi:alert", + "assist": "mdi:steering" + } + }, + "lateral_acceleration": { + "default": "mdi:axis-arrow" + }, + "lifetime_energy_used": { + "default": "mdi:lightning-bolt" + }, + "lifetime_energy_used_drive": { + "default": "mdi:lightning-bolt" + }, + "longitudinal_acceleration": { + "default": "mdi:axis-arrow" + }, + "module_temp_max": { + "default": "mdi:thermometer-high" + }, + "module_temp_min": { + "default": "mdi:thermometer-low" + }, + "pack_current": { + "default": "mdi:current-dc" + }, + "pack_voltage": { + "default": "mdi:lightning-bolt" + }, + "paired_phone_key_and_key_fob_qty": { + "default": "mdi:key" + }, + "pedal_position": { + "default": "mdi:pedestal" + }, + "powershare_hours_left": { + "default": "mdi:clock-time-eight-outline" + }, + "powershare_instantaneous_power_kw": { + "default": "mdi:flash" + }, + "powershare_status": { + "default": "mdi:power-socket", + "state": { + "inactive": "mdi:power-plug-off-outline", + "handshaking": "mdi:handshake", + "init": "mdi:cog-refresh", + "enabled": "mdi:check-circle", + "reconnecting": "mdi:wifi-refresh", + "stopped": "mdi:stop-circle" + } + }, + "powershare_stop_reason": { + "default": "mdi:stop-circle", + "state": { + "soc_too_low": "mdi:battery-low", + "retry": "mdi:refresh", + "fault": "mdi:alert-circle", + "user": "mdi:account", + "reconnecting": "mdi:wifi-refresh", + "authentication": "mdi:shield-key" + } + }, + "powershare_type": { + "default": "mdi:power-socket", + "state": { + "load": "mdi:power-plug", + "home": "mdi:home" + } + }, + "rated_range": { + "default": "mdi:map-marker-distance" + }, + "route_last_updated": { + "default": "mdi:map-clock" + }, + "scheduled_charging_mode": { + "default": "mdi:calendar-clock", + "state": { + "off": "mdi:calendar" + } + }, + "software_update_expected_duration_minutes": { + "default": "mdi:update" + }, + "speed_limit_warning": { + "default": "mdi:car-cruise-control" + }, + "tonneau_tent_mode": { + "default": "mdi:tent", + "state": { + "moving": "mdi:sync", + "failed": "mdi:alert" + } + }, + "tpms_hard_warnings": { + "default": "mdi:car-tire-alert" + }, + "tpms_soft_warnings": { + "default": "mdi:car-tire-alert" + }, + "lights_turn_signal": { + "default": "mdi:car-light-dimmed", + "state": { + "left": "mdi:arrow-left-bold-box", + "right": "mdi:arrow-right-bold-box", + "both": "mdi:hazard-lights" + } + }, + "charge_rate_mile_per_hour": { + "default": "mdi:speedometer" + }, + "hvac_power_state": { + "default": "mdi:hvac", + "state": { + "precondition": "mdi:sun-thermometer", + "overheat_protection": "mdi:thermometer-alert", + "off": "mdi:hvac-off", + "on": "mdi:hvac" + } } }, "switch": { diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index a8f1fd0daec..5b7454b87b6 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==1.0.17", "teslemetry-stream==0.6.12"] + "requirements": ["tesla-fleet-api==1.0.17", "teslemetry-stream==0.7.7"] } diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index fd6cf12b5b9..4f0d26a1cba 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -37,6 +37,7 @@ class TeslemetryVehicleData: api: Vehicle config_entry: ConfigEntry coordinator: TeslemetryVehicleDataCoordinator + poll: bool stream: TeslemetryStream stream_vehicle: TeslemetryStreamVehicle vin: str diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index ff25dec59b8..117c0a8c233 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -243,6 +243,7 @@ class TeslemetryStreamingNumberEntity( self._attr_native_value = last_number_data.native_value if last_number_data.native_max_value: self._attr_native_max_value = last_number_data.native_max_value + self.async_write_ha_state() # Add listeners self.async_on_remove( diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index b1c6b487bf9..b87bd334e8c 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -6,9 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from propcache.api import cached_property -from teslemetry_stream import Signal, TeslemetryStreamVehicle -from teslemetry_stream.const import ShiftState +from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.sensor import ( RestoreSensor, @@ -18,6 +16,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + DEGREE, PERCENTAGE, EntityCategory, UnitOfElectricCurrent, @@ -50,6 +49,18 @@ from .models import TeslemetryEnergyData, TeslemetryVehicleData PARALLEL_UPDATES = 0 +BMS_STATES = { + "Standby": "standby", + "Drive": "drive", + "Support": "support", + "Charge": "charge", + "FEIM": "full_electric_in_motion", + "ClearFault": "clear_fault", + "Fault": "fault", + "Weld": "weld", + "Test": "test", + "SNA": "system_not_available", +} CHARGE_STATES = { "Starting": "starting", @@ -60,8 +71,117 @@ CHARGE_STATES = { "NoPower": "no_power", } +DRIVE_INVERTER_STATES = { + "Unavailable": "unavailable", + "Standby": "standby", + "Fault": "fault", + "Abort": "abort", + "Enable": "enabled", +} + SHIFT_STATES = {"P": "p", "D": "d", "R": "r", "N": "n"} +SENTRY_MODE_STATES = { + "Off": "off", + "Idle": "idle", + "Armed": "armed", + "Aware": "aware", + "Panic": "panic", + "Quiet": "quiet", +} + +POWER_SHARE_STATES = { + "Inactive": "inactive", + "Handshaking": "handshaking", + "Init": "init", + "Enabled": "enabled", + "EnabledReconnectingSoon": "reconnecting", + "Stopped": "stopped", +} + +POWER_SHARE_STOP_REASONS = { + "None": "none", + "SOCTooLow": "soc_too_low", + "Retry": "retry", + "Fault": "fault", + "User": "user", + "Reconnecting": "reconnecting", + "Authentication": "authentication", +} + +POWER_SHARE_TYPES = { + "None": "none", + "Load": "load", + "Home": "home", +} + +FORWARD_COLLISION_SENSITIVITIES = { + "Off": "off", + "Late": "late", + "Average": "average", + "Early": "early", +} + +GUEST_MODE_MOBILE_ACCESS_STATES = { + "Init": "init", + "NotAuthenticated": "not_authenticated", + "Authenticated": "authenticated", + "AbortedDriving": "aborted_driving", + "AbortedUsingRemoteStart": "aborted_using_remote_start", + "AbortedUsingBLEKeys": "aborted_using_ble_keys", + "AbortedValetMode": "aborted_valet_mode", + "AbortedGuestModeOff": "aborted_guest_mode_off", + "AbortedDriveAuthTimeExceeded": "aborted_drive_auth_time_exceeded", + "AbortedNoDataReceived": "aborted_no_data_received", + "RequestingFromMothership": "requesting_from_mothership", + "RequestingFromAuthD": "requesting_from_auth_d", + "AbortedFetchFailed": "aborted_fetch_failed", + "AbortedBadDataReceived": "aborted_bad_data_received", + "ShowingQRCode": "showing_qr_code", + "SwipedAway": "swiped_away", + "DismissedQRCodeExpired": "dismissed_qr_code_expired", + "SucceededPairedNewBLEKey": "succeeded_paired_new_ble_key", +} + +HVAC_POWER_STATES = { + "Off": "off", + "On": "on", + "Precondition": "precondition", + "OverheatProtect": "overheat_protection", +} + +LANE_ASSIST_LEVELS = { + "None": "off", + "Warning": "warning", + "Assist": "assist", +} + +SCHEDULED_CHARGING_MODES = { + "Off": "off", + "StartAt": "start_at", + "DepartBy": "depart_by", +} + +SPEED_ASSIST_LEVELS = { + "None": "none", + "Display": "display", + "Chime": "chime", +} + +TONNEAU_TENT_MODE_STATES = { + "Inactive": "inactive", + "Moving": "moving", + "Failed": "failed", + "Active": "active", +} + +TURN_SIGNAL_STATES = { + "Off": "off", + "Left": "left", + "Right": "right", + "Both": "both", +} + @dataclass(frozen=True, kw_only=True) class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): @@ -70,8 +190,13 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): polling: bool = False polling_value_fn: Callable[[StateType], StateType] = lambda x: x nullable: bool = False - streaming_key: Signal | None = None - streaming_value_fn: Callable[[str | int | float], StateType] = lambda x: x + streaming_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[StateType], None]], + Callable[[], None], + ] + | None + ) = None streaming_firmware: str = "2024.26" @@ -79,18 +204,19 @@ 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", "") + streaming_listener=lambda vehicle, callback: vehicle.listen_DetailedChargeState( + lambda value: None if value is None else callback(value.lower()) ), + polling_value_fn=lambda value: CHARGE_STATES.get(str(value)), options=list(CHARGE_STATES.values()), device_class=SensorDeviceClass.ENUM, ), TeslemetryVehicleSensorEntityDescription( key="charge_state_battery_level", polling=True, - streaming_key=Signal.BATTERY_LEVEL, + streaming_listener=lambda vehicle, callback: vehicle.listen_BatteryLevel( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -99,15 +225,19 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_usable_battery_level", polling=True, + streaming_listener=lambda vehicle, callback: vehicle.listen_Soc(callback), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_registry_enabled_default=False, + suggested_display_precision=1, ), TeslemetryVehicleSensorEntityDescription( key="charge_state_charge_energy_added", polling=True, - streaming_key=Signal.AC_CHARGING_ENERGY_IN, + streaming_listener=lambda vehicle, callback: vehicle.listen_ACChargingEnergyIn( + callback + ), state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -116,7 +246,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_power", polling=True, - streaming_key=Signal.AC_CHARGING_POWER, + streaming_listener=lambda vehicle, callback: vehicle.listen_ACChargingPower( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, @@ -124,6 +256,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_voltage", polling=True, + streaming_listener=lambda vehicle, callback: vehicle.listen_ChargerVoltage( + callback + ), + streaming_firmware="2024.44.32", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -132,7 +268,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_actual_current", polling=True, - streaming_key=Signal.CHARGE_AMPS, + streaming_listener=lambda vehicle, callback: vehicle.listen_ChargeAmps( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -149,14 +287,18 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_conn_charge_cable", polling=True, - streaming_key=Signal.CHARGING_CABLE_TYPE, + streaming_listener=lambda vehicle, callback: vehicle.listen_ChargingCableType( + callback + ), entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryVehicleSensorEntityDescription( key="charge_state_fast_charger_type", polling=True, - streaming_key=Signal.FAST_CHARGER_TYPE, + streaming_listener=lambda vehicle, callback: vehicle.listen_FastChargerType( + callback + ), entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -171,7 +313,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_est_battery_range", polling=True, - streaming_key=Signal.EST_BATTERY_RANGE, + streaming_listener=lambda vehicle, callback: vehicle.listen_EstBatteryRange( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -181,7 +325,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_ideal_battery_range", polling=True, - streaming_key=Signal.IDEAL_BATTERY_RANGE, + streaming_listener=lambda vehicle, callback: vehicle.listen_IdealBatteryRange( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -192,7 +338,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( key="drive_state_speed", polling=True, polling_value_fn=lambda value: value or 0, - streaming_key=Signal.VEHICLE_SPEED, + streaming_listener=lambda vehicle, callback: vehicle.listen_VehicleSpeed( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.SPEED, @@ -211,10 +359,11 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="drive_state_shift_state", polling=True, - nullable=True, polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"), - streaming_key=Signal.GEAR, - streaming_value_fn=lambda x: str(ShiftState.get(x, "P")).lower(), + nullable=True, + streaming_listener=lambda vehicle, callback: vehicle.listen_Gear( + lambda value: callback("p" if value is None else value.lower()) + ), options=list(SHIFT_STATES.values()), device_class=SensorDeviceClass.ENUM, entity_registry_enabled_default=False, @@ -222,7 +371,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="vehicle_state_odometer", polling=True, - streaming_key=Signal.ODOMETER, + streaming_listener=lambda vehicle, callback: vehicle.listen_Odometer(callback), state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -233,7 +382,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_fl", polling=True, - streaming_key=Signal.TPMS_PRESSURE_FL, + streaming_listener=lambda vehicle, callback: vehicle.listen_TpmsPressureFl( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -245,7 +396,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_fr", polling=True, - streaming_key=Signal.TPMS_PRESSURE_FR, + streaming_listener=lambda vehicle, callback: vehicle.listen_TpmsPressureFr( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -257,7 +410,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_rl", polling=True, - streaming_key=Signal.TPMS_PRESSURE_RL, + streaming_listener=lambda vehicle, callback: vehicle.listen_TpmsPressureRl( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -269,7 +424,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_rr", polling=True, - streaming_key=Signal.TPMS_PRESSURE_RR, + streaming_listener=lambda vehicle, callback: vehicle.listen_TpmsPressureRr( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -281,7 +438,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="climate_state_inside_temp", polling=True, - streaming_key=Signal.INSIDE_TEMP, + streaming_listener=lambda vehicle, callback: vehicle.listen_InsideTemp( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -290,7 +449,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="climate_state_outside_temp", polling=True, - streaming_key=Signal.OUTSIDE_TEMP, + streaming_listener=lambda vehicle, callback: vehicle.listen_OutsideTemp( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -319,7 +480,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_traffic_minutes_delay", polling=True, - streaming_key=Signal.ROUTE_TRAFFIC_MINUTES_DELAY, + streaming_listener=lambda vehicle, + callback: vehicle.listen_RouteTrafficMinutesDelay(callback), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.MINUTES, device_class=SensorDeviceClass.DURATION, @@ -328,7 +490,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_energy_at_arrival", polling=True, - streaming_key=Signal.EXPECTED_ENERGY_PERCENT_AT_TRIP_ARRIVAL, + streaming_listener=lambda vehicle, + callback: vehicle.listen_ExpectedEnergyPercentAtTripArrival(callback), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -338,11 +501,840 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_miles_to_arrival", polling=True, - streaming_key=Signal.MILES_TO_ARRIVAL, + streaming_listener=lambda vehicle, callback: vehicle.listen_MilesToArrival( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, ), + TeslemetryVehicleSensorEntityDescription( + key="bms_state", + streaming_listener=lambda vehicle, callback: vehicle.listen_BMSState( + lambda value: None if value is None else callback(BMS_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(BMS_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="brake_pedal_position", + streaming_listener=lambda vehicle, callback: vehicle.listen_BrakePedalPos( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="brick_voltage_max", + streaming_listener=lambda vehicle, callback: vehicle.listen_BrickVoltageMax( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="brick_voltage_min", + streaming_listener=lambda vehicle, callback: vehicle.listen_BrickVoltageMin( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="cruise_follow_distance", + streaming_listener=lambda vehicle, + callback: vehicle.listen_CruiseFollowDistance(callback), + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="cruise_set_speed", + streaming_listener=lambda vehicle, callback: vehicle.listen_CruiseSetSpeed( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="current_limit_mph", + streaming_listener=lambda vehicle, callback: vehicle.listen_CurrentLimitMph( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="dc_charging_energy_in", + streaming_listener=lambda vehicle, callback: vehicle.listen_DCChargingEnergyIn( + callback + ), + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="dc_charging_power", + streaming_listener=lambda vehicle, callback: vehicle.listen_DCChargingPower( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_axle_speed_f", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiAxleSpeedF( + callback + ), + native_unit_of_measurement="rad/s", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_axle_speed_r", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiAxleSpeedR( + callback + ), + native_unit_of_measurement="rad/s", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_axle_speed_rel", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiAxleSpeedREL( + callback + ), + native_unit_of_measurement="rad/s", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_axle_speed_rer", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiAxleSpeedRER( + callback + ), + native_unit_of_measurement="rad/s", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_heatsink_tf", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiHeatsinkTF( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_heatsink_tr", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiHeatsinkTR( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_heatsink_trel", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiHeatsinkTREL( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_heatsink_trer", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiHeatsinkTRER( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_inverter_tf", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiInverterTF( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_inverter_tr", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiInverterTR( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_inverter_trel", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiInverterTREL( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_inverter_trer", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiInverterTRER( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_motor_current_f", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiMotorCurrentF( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_motor_current_r", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiMotorCurrentR( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_motor_current_rel", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiMotorCurrentREL( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_motor_current_rer", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiMotorCurrentRER( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_slave_torque_cmd", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiSlaveTorqueCmd( + callback + ), + native_unit_of_measurement="Nm", # Newton-meters + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_state_f", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiStateF( + lambda value: None + if value is None + else callback(DRIVE_INVERTER_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(DRIVE_INVERTER_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_state_r", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiStateR( + lambda value: None + if value is None + else callback(DRIVE_INVERTER_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(DRIVE_INVERTER_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_state_rel", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiStateREL( + lambda value: None + if value is None + else callback(DRIVE_INVERTER_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(DRIVE_INVERTER_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_state_rer", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiStateRER( + lambda value: None + if value is None + else callback(DRIVE_INVERTER_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(DRIVE_INVERTER_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_stator_temp_f", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiStatorTempF( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_stator_temp_r", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiStatorTempR( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_stator_temp_rel", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiStatorTempREL( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_stator_temp_rer", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiStatorTempRER( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_torque_actual_f", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiTorqueActualF( + callback + ), + native_unit_of_measurement="Nm", # Newton-meters + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_torque_actual_r", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiTorqueActualR( + callback + ), + native_unit_of_measurement="Nm", # Newton-meters + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_torque_actual_rel", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiTorqueActualREL( + callback + ), + native_unit_of_measurement="Nm", # Newton-meters + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_torque_actual_rer", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiTorqueActualRER( + callback + ), + native_unit_of_measurement="Nm", # Newton-meters + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_torquemotor", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiTorquemotor( + callback + ), + native_unit_of_measurement="Nm", # Newton-meters + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_vbat_f", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiVBatF(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_vbat_r", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiVBatR(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_vbat_rel", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiVBatREL(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_vbat_rer", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiVBatRER(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="sentry_mode", + streaming_listener=lambda vehicle, callback: vehicle.listen_SentryMode( + lambda value: None + if value is None + else callback(SENTRY_MODE_STATES.get(value)) + ), + options=list(SENTRY_MODE_STATES.values()), + device_class=SensorDeviceClass.ENUM, + ), + TeslemetryVehicleSensorEntityDescription( + key="energy_remaining", + streaming_listener=lambda vehicle, callback: vehicle.listen_EnergyRemaining( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="estimated_hours_to_charge_termination", + streaming_listener=lambda vehicle, + callback: vehicle.listen_EstimatedHoursToChargeTermination(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="forward_collision_warning", + streaming_listener=lambda vehicle, + callback: vehicle.listen_ForwardCollisionWarning( + lambda value: None + if value is None + else callback(FORWARD_COLLISION_SENSITIVITIES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(FORWARD_COLLISION_SENSITIVITIES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="gps_heading", + streaming_listener=lambda vehicle, callback: vehicle.listen_GpsHeading( + callback + ), + native_unit_of_measurement=DEGREE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="guest_mode_mobile_access_state", + streaming_listener=lambda vehicle, + callback: vehicle.listen_GuestModeMobileAccessState( + lambda value: None + if value is None + else callback(GUEST_MODE_MOBILE_ACCESS_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(GUEST_MODE_MOBILE_ACCESS_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="homelink_device_count", + streaming_listener=lambda vehicle, callback: vehicle.listen_HomelinkDeviceCount( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="hvac_fan_speed", + streaming_listener=lambda vehicle, callback: vehicle.listen_HvacFanSpeed( + lambda x: callback(None) if x is None else callback(x * 10) + ), + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="hvac_fan_status", + streaming_listener=lambda vehicle, callback: vehicle.listen_HvacFanStatus( + lambda x: callback(None) if x is None else callback(x * 10) + ), + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="isolation_resistance", + streaming_listener=lambda vehicle, callback: vehicle.listen_IsolationResistance( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement="Ω", + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="lane_departure_avoidance", + streaming_listener=lambda vehicle, + callback: vehicle.listen_LaneDepartureAvoidance( + lambda value: None + if value is None + else callback(LANE_ASSIST_LEVELS.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(LANE_ASSIST_LEVELS.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="lateral_acceleration", + streaming_listener=lambda vehicle, callback: vehicle.listen_LateralAcceleration( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="g", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="lifetime_energy_used", + streaming_listener=lambda vehicle, callback: vehicle.listen_LifetimeEnergyUsed( + callback + ), + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="longitudinal_acceleration", + streaming_listener=lambda vehicle, + callback: vehicle.listen_LongitudinalAcceleration(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="g", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="module_temp_max", + streaming_listener=lambda vehicle, callback: vehicle.listen_ModuleTempMax( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="module_temp_min", + streaming_listener=lambda vehicle, callback: vehicle.listen_ModuleTempMin( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="pack_current", + streaming_listener=lambda vehicle, callback: vehicle.listen_PackCurrent( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="pack_voltage", + streaming_listener=lambda vehicle, callback: vehicle.listen_PackVoltage( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="paired_phone_key_and_key_fob_qty", + streaming_listener=lambda vehicle, + callback: vehicle.listen_PairedPhoneKeyAndKeyFobQty(callback), + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="pedal_position", + streaming_listener=lambda vehicle, callback: vehicle.listen_PedalPosition( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="powershare_hours_left", + streaming_listener=lambda vehicle, callback: vehicle.listen_PowershareHoursLeft( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="powershare_instantaneous_power_kw", + streaming_listener=lambda vehicle, + callback: vehicle.listen_PowershareInstantaneousPowerKW(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="powershare_status", + streaming_listener=lambda vehicle, callback: vehicle.listen_PowershareStatus( + lambda value: None + if value is None + else callback(POWER_SHARE_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(POWER_SHARE_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="powershare_stop_reason", + streaming_listener=lambda vehicle, + callback: vehicle.listen_PowershareStopReason( + lambda value: None + if value is None + else callback(POWER_SHARE_STOP_REASONS.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(POWER_SHARE_STOP_REASONS.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="powershare_type", + streaming_listener=lambda vehicle, callback: vehicle.listen_PowershareType( + lambda value: None + if value is None + else callback(POWER_SHARE_TYPES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(POWER_SHARE_TYPES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="rated_range", + streaming_listener=lambda vehicle, callback: vehicle.listen_RatedRange( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="scheduled_charging_mode", + streaming_listener=lambda vehicle, + callback: vehicle.listen_ScheduledChargingMode( + lambda value: None + if value is None + else callback(SCHEDULED_CHARGING_MODES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(SCHEDULED_CHARGING_MODES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="software_update_expected_duration_minutes", + streaming_listener=lambda vehicle, + callback: vehicle.listen_SoftwareUpdateExpectedDurationMinutes(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="speed_limit_warning", + streaming_listener=lambda vehicle, callback: vehicle.listen_SpeedLimitWarning( + lambda value: None + if value is None + else callback(SPEED_ASSIST_LEVELS.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(SPEED_ASSIST_LEVELS.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="tonneau_tent_mode", + streaming_listener=lambda vehicle, callback: vehicle.listen_TonneauTentMode( + lambda value: None + if value is None + else callback(TONNEAU_TENT_MODE_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(TONNEAU_TENT_MODE_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="tpms_hard_warnings", + streaming_listener=lambda vehicle, callback: vehicle.listen_TpmsHardWarnings( + callback + ), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="tpms_soft_warnings", + streaming_listener=lambda vehicle, callback: vehicle.listen_TpmsSoftWarnings( + callback + ), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="lights_turn_signal", + streaming_listener=lambda vehicle, callback: vehicle.listen_LightsTurnSignal( + lambda value: None + if value is None + else callback(TURN_SIGNAL_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(TURN_SIGNAL_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="charge_rate_mile_per_hour", + streaming_listener=lambda vehicle, + callback: vehicle.listen_ChargeRateMilePerHour(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="hvac_power_state", + streaming_listener=lambda vehicle, callback: vehicle.listen_HvacPower( + lambda value: None + if value is None + else callback(HVAC_POWER_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(HVAC_POWER_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), ) @@ -356,21 +1348,26 @@ class TeslemetryTimeEntityDescription(SensorEntityDescription): Callable[[], None], ] streaming_firmware: str = "2024.26" - streaming_value_fn: Callable[[float], float] = lambda x: x + streaming_unit: str VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = ( TeslemetryTimeEntityDescription( key="charge_state_minutes_to_full_charge", - streaming_value_fn=lambda x: x * 60, - streaming_listener=lambda x, y: x.listen_TimeToFullCharge(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_TimeToFullCharge( + callback + ), + streaming_unit="hours", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, variance=4, ), TeslemetryTimeEntityDescription( key="drive_state_active_route_minutes_to_arrival", - streaming_listener=lambda x, y: x.listen_MinutesToArrival(y), + streaming_listener=lambda vehicle, callback: vehicle.listen_MinutesToArrival( + callback + ), + streaming_unit="minutes", device_class=SensorDeviceClass.TIMESTAMP, variance=1, ), @@ -514,7 +1511,10 @@ ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, ), - SensorEntityDescription(key="version"), + SensorEntityDescription( + key="version", + entity_category=EntityCategory.DIAGNOSTIC, + ), ) ENERGY_HISTORY_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = tuple( @@ -545,7 +1545,7 @@ async def async_setup_entry( for description in VEHICLE_DESCRIPTIONS: if ( not vehicle.api.pre2021 - and description.streaming_key + and description.streaming_listener and vehicle.firmware >= description.streaming_firmware ): entities.append(TeslemetryStreamSensorEntity(vehicle, description)) @@ -611,8 +1611,7 @@ class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor) ) -> None: """Initialize the sensor.""" self.entity_description = description - assert description.streaming_key - super().__init__(data, description.key, description.streaming_key) + super().__init__(data, description.key) async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -621,17 +1620,17 @@ class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor) 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 + if self.entity_description.streaming_listener is not None: + self.async_on_remove( + self.entity_description.streaming_listener( + self.vehicle.stream_vehicle, self._async_value_from_stream + ) + ) - def _async_value_from_stream(self, value) -> None: + def _async_value_from_stream(self, value: StateType) -> None: """Update the value of the entity.""" - if self.entity_description.nullable or value is not None: - self._attr_native_value = self.entity_description.streaming_value_fn(value) - else: - self._attr_native_value = None + self._attr_native_value = value + self.async_write_ha_state() class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): @@ -674,7 +1673,7 @@ class TeslemetryStreamTimeSensorEntity(TeslemetryVehicleStreamEntity, SensorEnti self.entity_description = description self._get_timestamp = ignore_variance( func=lambda value: dt_util.now() - + timedelta(minutes=description.streaming_value_fn(value)), + + timedelta(**{self.entity_description.streaming_unit: value}), ignored_variance=timedelta(minutes=description.variance), ) super().__init__(data, description.key) @@ -694,6 +1693,7 @@ class TeslemetryStreamTimeSensorEntity(TeslemetryVehicleStreamEntity, SensorEnti self._attr_native_value = None else: self._attr_native_value = self._get_timestamp(value) + self.async_write_ha_state() class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity): diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index 8215adb5711..2f21073d227 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -12,7 +12,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import DOMAIN -from .helpers import handle_command, handle_vehicle_command, wake_up_vehicle +from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryEnergyData, TeslemetryVehicleData _LOGGER = logging.getLogger(__name__) @@ -107,7 +107,6 @@ def async_register_services(hass: HomeAssistant) -> None: config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) - await wake_up_vehicle(vehicle) await handle_vehicle_command( vehicle.api.navigation_gps_request( lat=call.data[ATTR_GPS][CONF_LATITUDE], @@ -148,7 +147,6 @@ def async_register_services(hass: HomeAssistant) -> None: translation_domain=DOMAIN, translation_key="set_scheduled_charging_time" ) - await wake_up_vehicle(vehicle) await handle_vehicle_command( vehicle.api.set_scheduled_charging(enable=call.data["enable"], time=time) ) @@ -205,7 +203,6 @@ def async_register_services(hass: HomeAssistant) -> None: translation_key="set_scheduled_departure_off_peak", ) - await wake_up_vehicle(vehicle) await handle_vehicle_command( vehicle.api.set_scheduled_departure( enable, @@ -242,7 +239,6 @@ def async_register_services(hass: HomeAssistant) -> None: config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) - await wake_up_vehicle(vehicle) await handle_vehicle_command( vehicle.api.set_valet_mode( call.data.get("enable"), call.data.get("pin", "") @@ -268,7 +264,6 @@ def async_register_services(hass: HomeAssistant) -> None: config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) - await wake_up_vehicle(vehicle) enable = call.data.get("enable") if enable is True: await handle_vehicle_command( diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index ceb8b3c1af9..54568c971c4 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Account is already configured", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_account_mismatch": "The reauthentication account does not match the original account" }, @@ -65,6 +65,12 @@ "state": { "name": "Status" }, + "cellular": { + "name": "Cellular" + }, + "wifi": { + "name": "Wi-Fi" + }, "storm_mode_active": { "name": "Storm watch active" }, @@ -190,6 +196,33 @@ }, "located_at_favorite": { "name": "Located at favorite" + }, + "charge_enable_request": { + "name": "Charge enable request" + }, + "defrost_for_preconditioning": { + "name": "Defrost for preconditioning" + }, + "lights_high_beams": { + "name": "High beams" + }, + "seat_vent_enabled": { + "name": "Seat vent enabled" + }, + "speed_limit_mode": { + "name": "Speed limited" + }, + "remote_start_enabled": { + "name": "Remote start" + }, + "hvil": { + "name": "High voltage interlock loop fault" + }, + "hvac_auto_mode": { + "name": "HVAC auto mode" + }, + "grid_status": { + "name": "Grid status" } }, "button": { @@ -221,7 +254,7 @@ "state_attributes": { "preset_mode": { "state": { - "off": "Normal", + "off": "[%key:common::state::normal%]", "keep": "Keep mode", "dog": "Dog mode", "camp": "Camp mode" @@ -262,72 +295,72 @@ "climate_state_seat_heater_left": { "name": "Seat heater front left", "state": { - "high": "High", - "low": "Low", - "medium": "Medium", - "off": "Off" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_center": { "name": "Seat heater rear center", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_left": { "name": "Seat heater rear left", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_right": { "name": "Seat heater rear right", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_right": { "name": "Seat heater front right", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_left": { "name": "Seat heater third row left", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_right": { "name": "Seat heater third row right", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_steering_wheel_heat_level": { "name": "Steering wheel heater", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "off": "[%key:common::state::off%]" } }, "components_customer_preferred_export_rule": { @@ -343,7 +376,7 @@ "state": { "autonomous": "Autonomous", "backup": "Backup", - "self_consumption": "Self consumption" + "self_consumption": "Self-consumption" } } }, @@ -363,7 +396,7 @@ "name": "Charge limit" }, "off_grid_vehicle_charging_reserve_percent": { - "name": "Off grid reserve" + "name": "Off-grid reserve" } }, "cover": { @@ -422,8 +455,8 @@ "state": { "starting": "Starting", "charging": "[%key:common::state::charging%]", - "disconnected": "Disconnected", - "stopped": "Stopped", + "disconnected": "[%key:common::state::disconnected%]", + "stopped": "[%key:common::state::stopped%]", "complete": "Complete", "no_power": "No power" } @@ -495,10 +528,10 @@ "name": "Island status", "state": { "island_status_unknown": "Unknown", - "on_grid": "On grid", - "off_grid": "Off grid", - "off_grid_intentional": "Off grid intentional", - "off_grid_unintentional": "Off grid unintentional" + "on_grid": "On-grid", + "off_grid": "Off-grid", + "off_grid_intentional": "Off-grid intentional", + "off_grid_unintentional": "Off-grid unintentional" } }, "load_power": { @@ -529,12 +562,12 @@ "name": "Tire pressure rear right" }, "version": { - "name": "version" + "name": "Version" }, "vin": { "name": "Vehicle", "state": { - "disconnected": "Disconnected" + "disconnected": "[%key:common::state::disconnected%]" } }, "vpp_backup_reserve_percent": { @@ -611,6 +644,378 @@ }, "total_grid_energy_exported": { "name": "Grid exported" + }, + + "sentry_mode": { + "name": "Sentry mode", + "state": { + "off": "[%key:common::state::off%]", + "idle": "[%key:common::state::idle%]", + "armed": "Armed", + "aware": "Aware", + "panic": "Panic", + "quiet": "Quiet" + } + }, + "bms_state": { + "name": "BMS state", + "state": { + "standby": "[%key:common::state::standby%]", + "drive": "Drive", + "support": "Support", + "charge": "Charge", + "full_electric_in_motion": "Full electric in motion", + "clear_fault": "Clear fault", + "fault": "[%key:common::state::fault%]", + "weld": "Weld", + "test": "Test", + "system_not_available": "System not available" + } + }, + "brake_pedal_position": { + "name": "Brake pedal position" + }, + "brick_voltage_max": { + "name": "Brick voltage max" + }, + "brick_voltage_min": { + "name": "Brick voltage min" + }, + "cruise_follow_distance": { + "name": "Cruise follow distance" + }, + "cruise_set_speed": { + "name": "Cruise set speed" + }, + "current_limit_mph": { + "name": "Current speed limit" + }, + "dc_charging_energy_in": { + "name": "DC charging energy in" + }, + "dc_charging_power": { + "name": "DC charging power" + }, + "di_axle_speed_f": { + "name": "Front drive inverter axle speed" + }, + "di_axle_speed_r": { + "name": "Rear drive inverter axle speed" + }, + "di_axle_speed_rel": { + "name": "Rear left drive inverter axle speed" + }, + "di_axle_speed_rer": { + "name": "Rear right drive inverter axle speed" + }, + "di_heatsink_tf": { + "name": "Front drive inverter heatsink temperature" + }, + "di_heatsink_tr": { + "name": "Rear drive inverter heatsink temperature" + }, + "di_heatsink_trel": { + "name": "Rear left drive inverter heatsink temperature" + }, + "di_heatsink_trer": { + "name": "Rear right drive inverter heatsink temperature" + }, + "di_inverter_tf": { + "name": "Front drive inverter temperature" + }, + "di_inverter_tr": { + "name": "Rear drive inverter temperature" + }, + "di_inverter_trel": { + "name": "Rear left drive inverter temperature" + }, + "di_inverter_trer": { + "name": "Rear right drive inverter temperature" + }, + "di_motor_current_f": { + "name": "Front drive inverter motor current" + }, + "di_motor_current_r": { + "name": "Rear drive inverter motor current" + }, + "di_motor_current_rel": { + "name": "Rear left drive inverter motor current" + }, + "di_motor_current_rer": { + "name": "Rear right drive inverter motor current" + }, + "di_slave_torque_cmd": { + "name": "Secondary drive unit torque" + }, + "di_state_f": { + "name": "Front drive inverter", + "state": { + "unavailable": "Unavailable", + "standby": "[%key:common::state::standby%]", + "fault": "[%key:common::state::fault%]", + "abort": "Abort", + "enabled": "[%key:common::state::enabled%]" + } + }, + "di_state_r": { + "name": "Rear drive inverter", + "state": { + "unavailable": "[%key:component::teslemetry::entity::sensor::di_state_f::state::unavailable%]", + "standby": "[%key:common::state::standby%]", + "fault": "[%key:common::state::fault%]", + "abort": "[%key:component::teslemetry::entity::sensor::di_state_f::state::abort%]", + "enabled": "[%key:common::state::enabled%]" + } + }, + "di_state_rel": { + "name": "Rear left drive inverter", + "state": { + "unavailable": "[%key:component::teslemetry::entity::sensor::di_state_f::state::unavailable%]", + "standby": "[%key:common::state::standby%]", + "fault": "[%key:common::state::fault%]", + "abort": "[%key:component::teslemetry::entity::sensor::di_state_f::state::abort%]", + "enabled": "[%key:common::state::enabled%]" + } + }, + "di_state_rer": { + "name": "Rear right drive inverter", + "state": { + "unavailable": "[%key:component::teslemetry::entity::sensor::di_state_f::state::unavailable%]", + "standby": "[%key:common::state::standby%]", + "fault": "[%key:common::state::fault%]", + "abort": "[%key:component::teslemetry::entity::sensor::di_state_f::state::abort%]", + "enabled": "[%key:common::state::enabled%]" + } + }, + "di_stator_temp_f": { + "name": "Front drive unit stator temperature" + }, + "di_stator_temp_r": { + "name": "Rear drive unit stator temperature" + }, + "di_stator_temp_rel": { + "name": "Rear left drive unit stator temperature" + }, + "di_stator_temp_rer": { + "name": "Rear right drive unit stator temperature" + }, + "di_torque_actual_f": { + "name": "Front drive unit actual torque" + }, + "di_torque_actual_r": { + "name": "Rear drive unit actual torque" + }, + "di_torque_actual_rel": { + "name": "Rear left drive unit actual torque" + }, + "di_torque_actual_rer": { + "name": "Rear right drive unit actual torque" + }, + "di_torquemotor": { + "name": "Drive unit torque" + }, + "di_vbat_f": { + "name": "Front drive inverter battery voltage" + }, + "di_vbat_r": { + "name": "Rear drive inverter battery voltage" + }, + "di_vbat_rel": { + "name": "Rear left drive inverter battery voltage" + }, + "di_vbat_rer": { + "name": "Rear right drive inverter battery voltage" + }, + "energy_remaining": { + "name": "Energy remaining" + }, + "estimated_hours_to_charge_termination": { + "name": "Estimated hours to charge termination" + }, + "forward_collision_warning": { + "name": "Forward collision warning", + "state": { + "off": "[%key:common::state::off%]", + "late": "Late", + "average": "Average", + "early": "Early" + } + }, + "gps_heading": { + "name": "GPS heading" + }, + "guest_mode_mobile_access_state": { + "name": "Guest mode mobile access", + "state": { + "init": "Init", + "not_authenticated": "Not authenticated", + "authenticated": "Authenticated", + "aborted_driving": "Aborted driving", + "aborted_using_remote_start": "Aborted using remote start", + "aborted_using_ble_keys": "Aborted using BLE keys", + "aborted_valet_mode": "Aborted valet mode", + "aborted_guest_mode_off": "Aborted guest mode off", + "aborted_drive_auth_time_exceeded": "Aborted drive auth time exceeded", + "aborted_no_data_received": "Aborted no data received", + "requesting_from_mothership": "Requesting from mothership", + "requesting_from_auth_d": "Requesting from Authd", + "aborted_fetch_failed": "Aborted fetch failed", + "aborted_bad_data_received": "Aborted bad data received", + "showing_qr_code": "Showing QR code", + "swiped_away": "Swiped away", + "dismissed_qr_code_expired": "Dismissed QR code expired", + "succeeded_paired_new_ble_key": "Succeeded paired new BLE key" + } + }, + "homelink_device_count": { + "name": "Homelink devices", + "unit_of_measurement": "devices" + }, + "hvac_fan_speed": { + "name": "HVAC fan speed setting" + }, + "hvac_fan_status": { + "name": "HVAC fan speed" + }, + "isolation_resistance": { + "name": "Isolation resistance" + }, + "lane_departure_avoidance": { + "name": "Lane departure avoidance", + "state": { + "off": "[%key:common::state::off%]", + "warning": "Warning", + "assist": "Assist" + } + }, + "lateral_acceleration": { + "name": "Lateral acceleration" + }, + "lifetime_energy_used": { + "name": "Lifetime energy used" + }, + "lifetime_energy_used_drive": { + "name": "Lifetime energy used drive" + }, + "longitudinal_acceleration": { + "name": "Longitudinal acceleration" + }, + "module_temp_max": { + "name": "Module temperature maximum" + }, + "module_temp_min": { + "name": "Module temperature minimum" + }, + "pack_current": { + "name": "Pack current" + }, + "pack_voltage": { + "name": "Pack voltage" + }, + "paired_phone_key_and_key_fob_qty": { + "name": "Paired phone key and key fob quantity" + }, + "pedal_position": { + "name": "Pedal position" + }, + "powershare_hours_left": { + "name": "Powershare hours left" + }, + "powershare_instantaneous_power_kw": { + "name": "Powershare instantaneous power" + }, + "powershare_status": { + "name": "Powershare status", + "state": { + "inactive": "Inactive", + "handshaking": "Handshaking", + "init": "Initializing", + "enabled": "[%key:common::state::enabled%]", + "reconnecting": "Reconnecting", + "stopped": "[%key:common::state::stopped%]" + } + }, + "powershare_stop_reason": { + "name": "Powershare stop reason", + "state": { + "soc_too_low": "SOC too low", + "retry": "Retry", + "fault": "[%key:common::state::fault%]", + "user": "User", + "reconnecting": "Reconnecting", + "authentication": "Authentication" + } + }, + "powershare_type": { + "name": "Powershare type", + "state": { + "none": "None", + "load": "Load", + "home": "Home" + } + }, + "rated_range": { + "name": "Rated range" + }, + "route_last_updated": { + "name": "Route last updated" + }, + "scheduled_charging_mode": { + "name": "Scheduled charging mode", + "state": { + "off": "[%key:common::state::off%]", + "departure": "Departure", + "start_at": "Start at" + } + }, + "software_update_expected_duration_minutes": { + "name": "Software update expected duration" + }, + "speed_limit_warning": { + "name": "Speed limit warning", + "state": { + "none": "None", + "display": "Display", + "chime": "Chime" + } + }, + "tonneau_tent_mode": { + "name": "Tonneau tent mode", + "state": { + "inactive": "Inactive", + "moving": "Moving", + "failed": "Failed", + "active": "Active" + } + }, + "tpms_hard_warnings": { + "name": "Tire pressure hard warnings", + "unit_of_measurement": "warnings" + }, + "tpms_soft_warnings": { + "name": "Tire pressure soft warnings", + "unit_of_measurement": "warnings" + }, + "lights_turn_signal": { + "name": "Turn signal", + "state": { + "off": "[%key:common::state::off%]", + "left": "Left", + "right": "Right", + "both": "Both" + } + }, + "charge_rate_mile_per_hour": { + "name": "Charge rate" + }, + "hvac_power_state": { + "name": "HVAC power state", + "state": { + "precondition": "Precondition", + "overheat_protection": "Overheat protection", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } }, "switch": { @@ -662,7 +1067,7 @@ "message": "Departure time required to enable preconditioning" }, "set_scheduled_departure_off_peak": { - "message": "To enable scheduled departure, end off peak time is required." + "message": "To enable scheduled departure, 'End off-peak time' is required." }, "invalid_device": { "message": "Invalid device ID: {device_id}" @@ -726,7 +1131,7 @@ }, "enable": { "description": "Enable or disable scheduled charging.", - "name": "Enable" + "name": "[%key:common::action::enable%]" }, "time": { "description": "Time to start charging.", @@ -748,19 +1153,19 @@ }, "enable": { "description": "Enable or disable scheduled departure.", - "name": "Enable" + "name": "[%key:common::action::enable%]" }, "end_off_peak_time": { "description": "Time to complete charging by.", - "name": "End off peak time" + "name": "End off-peak time" }, "off_peak_charging_enabled": { - "description": "Enable off peak charging.", - "name": "Off peak charging enabled" + "description": "Enable off-peak charging.", + "name": "Off-peak charging enabled" }, "off_peak_charging_weekdays_only": { - "description": "Enable off peak charging on weekdays only.", - "name": "Off peak charging weekdays only" + "description": "Enable off-peak charging on weekdays only.", + "name": "Off-peak charging weekdays only" }, "preconditioning_enabled": { "description": "Enable preconditioning.", @@ -782,7 +1187,7 @@ }, "enable": { "description": "Enable or disable speed limit.", - "name": "Enable" + "name": "[%key:common::action::enable%]" }, "pin": { "description": "4 digit PIN.", @@ -814,7 +1219,7 @@ }, "enable": { "description": "Enable or disable valet mode.", - "name": "Enable" + "name": "[%key:common::action::enable%]" }, "pin": { "description": "4 digit PIN.", diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index 516a6f9852f..645a8398820 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api.const import Scope +from tesla_fleet_api.const import AutoSeat, Scope from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.switch import ( @@ -62,15 +62,23 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_left", streaming_listener=lambda x, y: x.listen_AutoSeatClimateLeft(y), - on_func=lambda api: api.remote_auto_seat_climate_request(1, True), - off_func=lambda api: api.remote_auto_seat_climate_request(1, False), + on_func=lambda api: api.remote_auto_seat_climate_request( + AutoSeat.FRONT_LEFT, True + ), + off_func=lambda api: api.remote_auto_seat_climate_request( + AutoSeat.FRONT_LEFT, False + ), scopes=[Scope.VEHICLE_CMDS], ), TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_right", streaming_listener=lambda x, y: x.listen_AutoSeatClimateRight(y), - on_func=lambda api: api.remote_auto_seat_climate_request(2, True), - off_func=lambda api: api.remote_auto_seat_climate_request(2, False), + on_func=lambda api: api.remote_auto_seat_climate_request( + AutoSeat.FRONT_RIGHT, True + ), + off_func=lambda api: api.remote_auto_seat_climate_request( + AutoSeat.FRONT_RIGHT, False + ), scopes=[Scope.VEHICLE_CMDS], ), TeslemetrySwitchEntityDescription( diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 4f0f5f67ebd..f3455845fd7 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -48,7 +48,7 @@ "state_attributes": { "preset_mode": { "state": { - "off": "Normal", + "off": "[%key:common::state::normal%]", "on": "Keep mode", "dog": "Dog mode", "camp": "Camp mode" @@ -76,8 +76,8 @@ "state": { "starting": "Starting", "charging": "[%key:common::state::charging%]", - "disconnected": "Disconnected", - "stopped": "Stopped", + "disconnected": "[%key:common::state::disconnected%]", + "stopped": "[%key:common::state::stopped%]", "complete": "Complete", "no_power": "No power" } @@ -217,7 +217,7 @@ "connected": "[%key:common::state::connected%]", "scheduled": "Scheduled", "negotiating": "Negotiating", - "error": "Error", + "error": "[%key:common::state::error%]", "charging_finished": "Charging finished", "waiting_car": "Waiting car", "charging_reduced": "Charging reduced" @@ -246,81 +246,81 @@ "name": "Seat heater left", "state": { "off": "[%key:common::state::off%]", - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_right": { "name": "Seat heater right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_rear_left": { "name": "Seat heater rear left", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_rear_center": { "name": "Seat heater rear center", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_rear_right": { "name": "Seat heater rear right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_third_row_left": { "name": "Seat heater third row left", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_third_row_right": { "name": "Seat heater third row right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_fan_front_left": { "name": "Seat cooler left", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_fan_front_right": { "name": "Seat cooler right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "components_customer_preferred_export_rule": { @@ -336,7 +336,7 @@ "state": { "autonomous": "Autonomous", "backup": "Backup", - "self_consumption": "Self consumption" + "self_consumption": "Self-consumption" } } }, @@ -495,7 +495,7 @@ "name": "Speed limit" }, "off_grid_vehicle_charging_reserve_percent": { - "name": "Off grid reserve" + "name": "Off-grid reserve" } }, "update": { diff --git a/homeassistant/components/thermador/__init__.py b/homeassistant/components/thermador/__init__.py new file mode 100644 index 00000000000..2bd83b2ff71 --- /dev/null +++ b/homeassistant/components/thermador/__init__.py @@ -0,0 +1 @@ +"""Thermador virtual integration.""" diff --git a/homeassistant/components/thermador/manifest.json b/homeassistant/components/thermador/manifest.json new file mode 100644 index 00000000000..b09861623de --- /dev/null +++ b/homeassistant/components/thermador/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "thermador", + "name": "Thermador", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/thermobeacon/manifest.json b/homeassistant/components/thermobeacon/manifest.json index b231137d335..d672de5adde 100644 --- a/homeassistant/components/thermobeacon/manifest.json +++ b/homeassistant/components/thermobeacon/manifest.json @@ -54,5 +54,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermobeacon", "iot_class": "local_push", - "requirements": ["thermobeacon-ble==0.8.1"] + "requirements": ["thermobeacon-ble==0.10.0"] } diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index 6027e4bc99c..29dadfd3d63 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.11.0"] + "requirements": ["thermopro-ble==0.13.0"] } diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py index 66a3b8b0e27..c81c791cd5d 100644 --- a/homeassistant/components/tile/device_tracker.py +++ b/homeassistant/components/tile/device_tracker.py @@ -64,7 +64,7 @@ class TileDeviceTracker(TileEntity, TrackerEntity): ) self._attr_latitude = None if not self._tile.latitude else self._tile.latitude self._attr_location_accuracy = ( - 0 if not self._tile.accuracy else int(self._tile.accuracy) + 0 if not self._tile.accuracy else self._tile.accuracy ) self._attr_extra_state_attributes = { diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 60e55c214fe..1e3c37b55b3 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -72,7 +72,7 @@ class TimeEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Time entity.""" entity_description: TimeEntityDescription - _attr_native_value: time | None + _attr_native_value: time | None = None _attr_device_class: None = None _attr_state: None = None diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 937187c1c6f..ea0448b7499 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -95,6 +95,12 @@ TODO_ITEM_FIELD_SCHEMA = { vol.Optional(desc.service_field): desc.validation for desc in TODO_ITEM_FIELDS } TODO_ITEM_FIELD_VALIDATIONS = [cv.has_at_most_one_key(ATTR_DUE_DATE, ATTR_DUE_DATETIME)] +TODO_SERVICE_GET_ITEMS_SCHEMA = { + vol.Optional(ATTR_STATUS): vol.All( + cv.ensure_list, + [vol.In({TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED})], + ), +} def _validate_supported_features( @@ -129,7 +135,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.All( cv.make_entity_service_schema( { - vol.Required(ATTR_ITEM): vol.All(cv.string, vol.Length(min=1)), + vol.Required(ATTR_ITEM): vol.All( + cv.string, str.strip, vol.Length(min=1) + ), **TODO_ITEM_FIELD_SCHEMA, } ), @@ -144,7 +152,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: cv.make_entity_service_schema( { vol.Required(ATTR_ITEM): vol.All(cv.string, vol.Length(min=1)), - vol.Optional(ATTR_RENAME): vol.All(cv.string, vol.Length(min=1)), + vol.Optional(ATTR_RENAME): vol.All( + cv.string, str.strip, vol.Length(min=1) + ), vol.Optional(ATTR_STATUS): vol.In( {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED}, ), @@ -173,14 +183,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) component.async_register_entity_service( TodoServices.GET_ITEMS, - cv.make_entity_service_schema( - { - vol.Optional(ATTR_STATUS): vol.All( - cv.ensure_list, - [vol.In({TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED})], - ), - } - ), + cv.make_entity_service_schema(TODO_SERVICE_GET_ITEMS_SCHEMA), _async_get_todo_items, supports_response=SupportsResponse.ONLY, ) @@ -219,18 +222,10 @@ class TodoItem: """A status or confirmation of the To-do item.""" due: datetime.date | datetime.datetime | None = None - """The date and time that a to-do is expected to be completed. - - This field may be a date or datetime depending whether the entity feature - DUE_DATE or DUE_DATETIME are set. - """ + """The date and time that a to-do is expected to be completed.""" description: str | None = None - """A more complete description of than that provided by the summary. - - This field may be set when TodoListEntityFeature.DESCRIPTION is supported by - the entity. - """ + """A more complete description than that provided by the summary.""" CACHED_PROPERTIES_WITH_ATTR_ = { diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index 07f91e12e22..8c26b8e9c76 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -100,6 +100,7 @@ remove_item: fields: item: required: true + example: "Submit income tax return" selector: text: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index cffb22e89f0..1354ab6777b 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -40,11 +40,11 @@ }, "update_item": { "name": "Update item", - "description": "Updates an existing to-do list item based on its name.", + "description": "Updates an existing to-do list item based on its name or UID.", "fields": { "item": { - "name": "Item name", - "description": "The current name of the to-do item." + "name": "Item name or UID", + "description": "The name/summary of the to-do item. If you have items with duplicate names, you can reference specific ones using their UID instead." }, "rename": { "name": "Rename item", @@ -55,16 +55,16 @@ "description": "A status or confirmation of the to-do item." }, "due_date": { - "name": "Due date", - "description": "The date the to-do item is expected to be completed." + "name": "[%key:component::todo::services::add_item::fields::due_date::name%]", + "description": "[%key:component::todo::services::add_item::fields::due_date::description%]" }, "due_datetime": { - "name": "Due date and time", - "description": "The date and time the to-do item is expected to be completed." + "name": "[%key:component::todo::services::add_item::fields::due_datetime::name%]", + "description": "[%key:component::todo::services::add_item::fields::due_datetime::description%]" }, "description": { - "name": "Description", - "description": "A more complete description of the to-do item than provided by the item name." + "name": "[%key:component::todo::services::add_item::fields::description::name%]", + "description": "[%key:component::todo::services::add_item::fields::description::description%]" } } }, @@ -74,11 +74,11 @@ }, "remove_item": { "name": "Remove item", - "description": "Removes an existing to-do list item by its name.", + "description": "Removes an existing to-do list item by its name or UID.", "fields": { "item": { - "name": "Item name", - "description": "The name for the to-do list item." + "name": "[%key:component::todo::services::update_item::fields::item::name%]", + "description": "[%key:component::todo::services::update_item::fields::item::description%]" } } } diff --git a/homeassistant/components/tolo/strings.json b/homeassistant/components/tolo/strings.json index c55498b8d92..82b6ecee9e7 100644 --- a/homeassistant/components/tolo/strings.json +++ b/homeassistant/components/tolo/strings.json @@ -59,7 +59,7 @@ "name": "Lamp mode", "state": { "automatic": "Automatic", - "manual": "Manual" + "manual": "[%key:common::state::manual%]" } }, "aroma_therapy_slot": { diff --git a/homeassistant/components/tomorrowio/strings.json b/homeassistant/components/tomorrowio/strings.json index 03a8a169920..c3f52155d29 100644 --- a/homeassistant/components/tomorrowio/strings.json +++ b/homeassistant/components/tomorrowio/strings.json @@ -115,33 +115,33 @@ "name": "Tree pollen index", "state": { "none": "None", - "very_low": "Very low", - "low": "Low", - "medium": "Medium", - "high": "High", - "very_high": "Very high" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } }, "weed_pollen_index": { "name": "Weed pollen index", "state": { "none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]", - "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]", - "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]", - "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]", - "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]", - "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } }, "grass_pollen_index": { "name": "Grass pollen index", "state": { "none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]", - "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]", - "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]", - "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]", - "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]", - "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } }, "fire_index": { @@ -153,10 +153,10 @@ "uv_radiation_health_concern": { "name": "UV radiation health concern", "state": { - "low": "Low", + "low": "[%key:common::state::low%]", "moderate": "Moderate", - "high": "High", - "very_high": "Very high", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]", "extreme": "Extreme" } } diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 9ed29ea01c8..e31e6085832 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -97,22 +97,6 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): @property def alarm_state(self) -> AlarmControlPanelState | None: """Return the state of the device.""" - # State attributes can be removed in 2025.3 - attr = { - "location_id": self._location.location_id, - "partition": self._partition_id, - "ac_loss": self._location.ac_loss, - "low_battery": self._location.low_battery, - "cover_tampered": self._location.is_cover_tampered(), - "triggered_source": None, - "triggered_zone": None, - } - - if self._partition_id == 1: - attr["location_name"] = self.device.name - else: - attr["location_name"] = f"{self.device.name} partition {self._partition_id}" - state: AlarmControlPanelState | None = None if self._partition.arming_state.is_disarmed(): state = AlarmControlPanelState.DISARMED @@ -128,17 +112,12 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): state = AlarmControlPanelState.ARMING elif self._partition.arming_state.is_disarming(): state = AlarmControlPanelState.DISARMING - elif self._partition.arming_state.is_triggered_police(): + elif ( + self._partition.arming_state.is_triggered_police() + or self._partition.arming_state.is_triggered_fire() + or self._partition.arming_state.is_triggered_gas() + ): state = AlarmControlPanelState.TRIGGERED - attr["triggered_source"] = "Police/Medical" - elif self._partition.arming_state.is_triggered_fire(): - state = AlarmControlPanelState.TRIGGERED - attr["triggered_source"] = "Fire/Smoke" - elif self._partition.arming_state.is_triggered_gas(): - state = AlarmControlPanelState.TRIGGERED - attr["triggered_source"] = "Carbon Monoxide" - - self._attr_extra_state_attributes = attr return state diff --git a/homeassistant/components/touchline_sl/strings.json b/homeassistant/components/touchline_sl/strings.json index e3a0ef5a741..469fb8a50a6 100644 --- a/homeassistant/components/touchline_sl/strings.json +++ b/homeassistant/components/touchline_sl/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "Touchline SL Setup Flow", + "flow_title": "Touchline SL setup flow", "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", @@ -8,7 +8,7 @@ }, "step": { "user": { - "title": "Login to Touchline SL", + "title": "Log in to Touchline SL", "description": "Your credentials for the Roth Touchline SL mobile app/web service", "data": { "username": "[%key:common::config_flow::data::username%]", diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 291a7e78c62..0914c4191cf 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -567,7 +567,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): ) async def _async_reload_requires_auth_entries(self) -> None: - """Reload any in progress config flow that now have credentials.""" + """Reload all config entries after auth update.""" _config_entries = self.hass.config_entries if self.source == SOURCE_REAUTH: @@ -579,11 +579,9 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): context = flow["context"] if context.get("source") != SOURCE_REAUTH: continue - entry_id: str = context["entry_id"] + entry_id = context["entry_id"] if entry := _config_entries.async_get_entry(entry_id): await _config_entries.async_reload(entry.entry_id) - if entry.state is ConfigEntryState.LOADED: - _config_entries.flow.async_abort(flow["flow_id"]) @callback def _async_create_or_update_entry_from_device( diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index ded4806a726..856b4d339a5 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -209,7 +209,7 @@ "name": "Last water leak alert" }, "auto_off_at": { - "name": "Auto off at" + "name": "Auto-off at" }, "report_interval": { "name": "Report interval" @@ -297,10 +297,10 @@ "name": "LED" }, "auto_update_enabled": { - "name": "Auto update enabled" + "name": "Auto-update enabled" }, "auto_off_enabled": { - "name": "Auto off enabled" + "name": "Auto-off enabled" }, "smooth_transitions": { "name": "Smooth transitions" @@ -388,7 +388,7 @@ }, "segments": { "name": "Segments", - "description": "List of Segments (0 for all)." + "description": "List of segments (0 for all)." }, "brightness": { "name": "Brightness", diff --git a/homeassistant/components/tplink_omada/config_flow.py b/homeassistant/components/tplink_omada/config_flow.py index eeeddb62495..6fec7d30381 100644 --- a/homeassistant/components/tplink_omada/config_flow.py +++ b/homeassistant/components/tplink_omada/config_flow.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Mapping import logging import re -from types import MappingProxyType from typing import Any, NamedTuple from urllib.parse import urlsplit @@ -45,7 +44,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( async def create_omada_client( - hass: HomeAssistant, data: MappingProxyType[str, Any] + hass: HomeAssistant, data: Mapping[str, Any] ) -> OmadaClient: """Create a TP-Link Omada client API for the given config entry.""" @@ -84,7 +83,7 @@ class HubInfo(NamedTuple): async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> HubInfo: """Validate the user input allows us to connect.""" - client = await create_omada_client(hass, MappingProxyType(data)) + client = await create_omada_client(hass, data) controller_id = await client.login() name = await client.get_controller_name() sites = await client.get_sites() diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json index 73cea692dbf..99c509a73a7 100644 --- a/homeassistant/components/tplink_omada/strings.json +++ b/homeassistant/components/tplink_omada/strings.json @@ -24,14 +24,14 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, - "title": "Update TP-Link Omada Credentials", + "title": "Update TP-Link Omada credentials", "description": "The provided credentials have stopped working. Please update them." } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unsupported_controller": "Omada Controller version not supported.", + "unsupported_controller": "Omada controller version not supported.", "unknown": "[%key:common::config_flow::error::unknown%]", "no_sites_found": "No sites found which the user can manage." }, @@ -46,31 +46,31 @@ "name": "Port {port_name} PoE" }, "wan_connect_ipv4": { - "name": "Port {port_name} Internet Connected" + "name": "Port {port_name} Internet connected" }, "wan_connect_ipv6": { - "name": "Port {port_name} Internet Connected (IPv6)" + "name": "Port {port_name} Internet connected (IPv6)" } }, "binary_sensor": { "wan_link": { - "name": "Port {port_name} Internet Link" + "name": "Port {port_name} Internet link" }, "online_detection": { - "name": "Port {port_name} Online Detection" + "name": "Port {port_name} online detection" }, "lan_status": { - "name": "Port {port_name} LAN Status" + "name": "Port {port_name} LAN status" }, "poe_delivery": { - "name": "Port {port_name} PoE Delivery" + "name": "Port {port_name} PoE delivery" } }, "sensor": { "device_status": { "name": "Device status", "state": { - "error": "Error", + "error": "[%key:common::state::error%]", "disconnected": "[%key:common::state::disconnected%]", "connected": "[%key:common::state::connected%]", "pending": "Pending", @@ -91,7 +91,7 @@ "services": { "reconnect_client": { "name": "Reconnect wireless client", - "description": "Tries to get wireless client to reconnect to Omada Network.", + "description": "Tries to get wireless client to reconnect to Omada network.", "fields": { "mac": { "name": "MAC address", diff --git a/homeassistant/components/traccar_server/device_tracker.py b/homeassistant/components/traccar_server/device_tracker.py index 7f2a6dd7c40..33a7e511d09 100644 --- a/homeassistant/components/traccar_server/device_tracker.py +++ b/homeassistant/components/traccar_server/device_tracker.py @@ -54,6 +54,6 @@ class TraccarServerDeviceTracker(TraccarServerEntity, TrackerEntity): return self.traccar_position["longitude"] @property - def location_accuracy(self) -> int: + def location_accuracy(self) -> float: """Return the gps accuracy of the device.""" return self.traccar_position["accuracy"] diff --git a/homeassistant/components/traccar_server/strings.json b/homeassistant/components/traccar_server/strings.json index 8bec4b112ac..a4b57562388 100644 --- a/homeassistant/components/traccar_server/strings.json +++ b/homeassistant/components/traccar_server/strings.json @@ -12,7 +12,7 @@ }, "data_description": { "host": "The hostname or IP address of your Traccar Server", - "username": "The username (email) you use to login to your Traccar Server" + "username": "The username (email) you use to log in to your Traccar Server" } } }, @@ -47,7 +47,7 @@ "motion": { "name": "Motion", "state": { - "off": "Stopped", + "off": "[%key:common::state::stopped%]", "on": "Moving" } }, diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 8bc2d11d047..60bae9bfd2e 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -31,6 +31,7 @@ from .const import ( ATTR_MINUTES_DAY_SLEEP, ATTR_MINUTES_NIGHT_SLEEP, ATTR_MINUTES_REST, + ATTR_POWER_SAVING, ATTR_SLEEP_LABEL, ATTR_TRACKER_STATE, CLIENT_ID, @@ -277,6 +278,7 @@ class TractiveClient: payload = { ATTR_BATTERY_LEVEL: event["hardware"]["battery_level"], ATTR_TRACKER_STATE: event["tracker_state"].lower(), + ATTR_POWER_SAVING: event.get("tracker_state_reason") == "POWER_SAVING", ATTR_BATTERY_CHARGING: event["charging_state"] == "CHARGING", } self._dispatch_tracker_event( diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py index 2978d369344..9ded1f699c3 100644 --- a/homeassistant/components/tractive/binary_sensor.py +++ b/homeassistant/components/tractive/binary_sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import Any from homeassistant.components.binary_sensor import ( @@ -14,7 +16,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Trackables, TractiveClient, TractiveConfigEntry -from .const import TRACKER_HARDWARE_STATUS_UPDATED +from .const import ATTR_POWER_SAVING, TRACKER_HARDWARE_STATUS_UPDATED from .entity import TractiveEntity @@ -25,7 +27,7 @@ class TractiveBinarySensor(TractiveEntity, BinarySensorEntity): self, client: TractiveClient, item: Trackables, - description: BinarySensorEntityDescription, + description: TractiveBinarySensorEntityDescription, ) -> None: """Initialize sensor entity.""" super().__init__( @@ -47,12 +49,27 @@ class TractiveBinarySensor(TractiveEntity, BinarySensorEntity): super().handle_status_update(event) -SENSOR_TYPE = BinarySensorEntityDescription( - key=ATTR_BATTERY_CHARGING, - translation_key="tracker_battery_charging", - device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - entity_category=EntityCategory.DIAGNOSTIC, -) +@dataclass(frozen=True, kw_only=True) +class TractiveBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Tractive binary sensor entities.""" + + supported: Callable[[dict], bool] = lambda _: True + + +SENSOR_TYPES = [ + TractiveBinarySensorEntityDescription( + key=ATTR_BATTERY_CHARGING, + translation_key="tracker_battery_charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + entity_category=EntityCategory.DIAGNOSTIC, + supported=lambda details: details.get("charging_state") is not None, + ), + TractiveBinarySensorEntityDescription( + key=ATTR_POWER_SAVING, + translation_key="tracker_power_saving", + entity_category=EntityCategory.DIAGNOSTIC, + ), +] async def async_setup_entry( @@ -65,9 +82,10 @@ async def async_setup_entry( trackables = entry.runtime_data.trackables entities = [ - TractiveBinarySensor(client, item, SENSOR_TYPE) + TractiveBinarySensor(client, item, description) + for description in SENSOR_TYPES for item in trackables - if item.tracker_details.get("charging_state") is not None + if description.supported(item.tracker_details) ] async_add_entities(entities) diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py index cb5d4066dd9..9b925015772 100644 --- a/homeassistant/components/tractive/const.py +++ b/homeassistant/components/tractive/const.py @@ -16,6 +16,7 @@ ATTR_MINUTES_ACTIVE = "minutes_active" ATTR_MINUTES_DAY_SLEEP = "minutes_day_sleep" ATTR_MINUTES_NIGHT_SLEEP = "minutes_night_sleep" ATTR_MINUTES_REST = "minutes_rest" +ATTR_POWER_SAVING = "power_saving" ATTR_SLEEP_LABEL = "sleep_label" ATTR_TRACKER_STATE = "tracker_state" diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index bd1380ade4c..09a4e3faf1f 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -49,7 +49,7 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): self._battery_level: int | None = item.hw_info.get("battery_level") self._attr_latitude = item.pos_report["latlong"][0] self._attr_longitude = item.pos_report["latlong"][1] - self._attr_location_accuracy: int = item.pos_report["pos_uncertainty"] + self._attr_location_accuracy: float = item.pos_report["pos_uncertainty"] self._source_type: str = item.pos_report["sensor_used"] self._attr_unique_id = item.trackable["_id"] diff --git a/homeassistant/components/tractive/strings.json b/homeassistant/components/tractive/strings.json index 0690328c99c..a56a2982057 100644 --- a/homeassistant/components/tractive/strings.json +++ b/homeassistant/components/tractive/strings.json @@ -22,6 +22,9 @@ "binary_sensor": { "tracker_battery_charging": { "name": "Tracker battery charging" + }, + "tracker_power_saving": { + "name": "Tracker power saving" } }, "device_tracker": { diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index a0babe7464a..feb84f09fa8 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -211,6 +211,7 @@ def _torrents_info_attr( "percent_done": f"{torrent.percent_done * 100:.2f}", "status": torrent.status, "id": torrent.id, + "ratio": torrent.ratio, } with suppress(ValueError): info["eta"] = str(torrent.eta) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index cb207643471..b279af31803 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -3,8 +3,8 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator -from dataclasses import dataclass +from collections.abc import AsyncGenerator, MutableMapping +from dataclasses import dataclass, field from datetime import datetime import hashlib from http import HTTPStatus @@ -14,10 +14,8 @@ import mimetypes import os import re import secrets -import subprocess -import tempfile from time import monotonic -from typing import Any, Final +from typing import Any, Final, Generic, Protocol, TypeVar from aiohttp import web import mutagen @@ -44,7 +42,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url from homeassistant.helpers.typing import UNDEFINED, ConfigType -from homeassistant.util import language as language_util +from homeassistant.util import language as language_util, ulid as ulid_util from .const import ( ATTR_CACHE, @@ -62,10 +60,10 @@ from .const import ( DOMAIN, TtsAudioType, ) -from .entity import TextToSpeechEntity, TTSAudioRequest +from .entity import TextToSpeechEntity, TTSAudioRequest, TTSAudioResponse from .helper import get_engine_instance from .legacy import PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, Provider, async_setup_legacy -from .media_source import generate_media_source_id, media_source_id_to_kwargs +from .media_source import generate_media_source_id, parse_media_source_id from .models import Voice __all__ = [ @@ -81,6 +79,7 @@ __all__ = [ "Provider", "ResultStream", "SampleFormat", + "TTSAudioResponse", "TextToSpeechEntity", "TtsAudioType", "Voice", @@ -266,7 +265,7 @@ def async_create_stream( @callback def async_get_stream(hass: HomeAssistant, token: str) -> ResultStream | None: """Return a result stream given a token.""" - return hass.data[DATA_TTS_MANAGER].token_to_stream.get(token) + return hass.data[DATA_TTS_MANAGER].async_get_result_stream(token) async def async_get_media_source_audio( @@ -274,12 +273,11 @@ async def async_get_media_source_audio( media_source_id: str, ) -> tuple[str, bytes]: """Get TTS audio as extension, data.""" - manager = hass.data[DATA_TTS_MANAGER] - cache = manager.async_cache_message_in_memory( - **media_source_id_to_kwargs(media_source_id) - ) - data = b"".join([chunk async for chunk in cache.async_stream_data()]) - return cache.extension, data + parsed = parse_media_source_id(media_source_id) + stream = hass.data[DATA_TTS_MANAGER].async_create_result_stream(**parsed["options"]) + stream.async_set_message(parsed["message"]) + data = b"".join([chunk async for chunk in stream.async_stream_result()]) + return stream.extension, data @callback @@ -309,80 +307,73 @@ async def _async_convert_audio( ) -> AsyncGenerator[bytes]: """Convert audio to a preferred format using ffmpeg.""" ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) - audio_bytes = b"".join([chunk async for chunk in audio_bytes_gen]) - data = await hass.async_add_executor_job( - lambda: _convert_audio( - ffmpeg_manager.binary, - from_extension, - audio_bytes, - to_extension, - to_sample_rate=to_sample_rate, - to_sample_channels=to_sample_channels, - to_sample_bytes=to_sample_bytes, - ) + + command = [ + ffmpeg_manager.binary, + "-hide_banner", + "-loglevel", + "error", + "-f", + from_extension, + "-i", + "pipe:", + "-f", + to_extension, + ] + if to_sample_rate is not None: + command.extend(["-ar", str(to_sample_rate)]) + if to_sample_channels is not None: + command.extend(["-ac", str(to_sample_channels)]) + if to_extension == "mp3": + # Max quality for MP3. + command.extend(["-q:a", "0"]) + if to_sample_bytes == 2: + # 16-bit samples. + command.extend(["-sample_fmt", "s16"]) + command.append("pipe:1") # Send output to stdout. + + process = await asyncio.create_subprocess_exec( + *command, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) - yield data + async def write_input() -> None: + assert process.stdin + try: + async for chunk in audio_bytes_gen: + process.stdin.write(chunk) + await process.stdin.drain() + finally: + if process.stdin: + process.stdin.close() -def _convert_audio( - ffmpeg_binary: str, - from_extension: str, - audio_bytes: bytes, - to_extension: str, - to_sample_rate: int | None = None, - to_sample_channels: int | None = None, - to_sample_bytes: int | None = None, -) -> bytes: - """Convert audio to a preferred format using ffmpeg.""" + writer_task = hass.async_create_background_task( + write_input(), "tts_ffmpeg_conversion" + ) - # We have to use a temporary file here because some formats like WAV store - # the length of the file in the header, and therefore cannot be written in a - # streaming fashion. - with tempfile.NamedTemporaryFile( - mode="wb+", suffix=f".{to_extension}" - ) as output_file: - # input - command = [ - ffmpeg_binary, - "-y", # overwrite temp file - "-f", - from_extension, - "-i", - "pipe:", # input from stdin - ] - - # output - command.extend(["-f", to_extension]) - - if to_sample_rate is not None: - command.extend(["-ar", str(to_sample_rate)]) - - if to_sample_channels is not None: - command.extend(["-ac", str(to_sample_channels)]) - - if to_extension == "mp3": - # Max quality for MP3 - command.extend(["-q:a", "0"]) - - if to_sample_bytes == 2: - # 16-bit samples - command.extend(["-sample_fmt", "s16"]) - - command.append(output_file.name) - - with subprocess.Popen( - command, stdin=subprocess.PIPE, stderr=subprocess.PIPE - ) as proc: - _stdout, stderr = proc.communicate(input=audio_bytes) - if proc.returncode != 0: - _LOGGER.error(stderr.decode()) - raise RuntimeError( - f"Unexpected error while running ffmpeg with arguments: {command}." - "See log for details." - ) - - output_file.seek(0) - return output_file.read() + assert process.stdout + chunk_size = 4096 + try: + while True: + chunk = await process.stdout.read(chunk_size) + if not chunk: + break + yield chunk + finally: + # Ensure we wait for the input writer to complete. + await writer_task + # Wait for process termination and check for errors. + retcode = await process.wait() + if retcode != 0: + assert process.stderr + stderr_data = await process.stderr.read() + _LOGGER.error(stderr_data.decode()) + raise RuntimeError( + f"Unexpected error while running ffmpeg with arguments: {command}. " + "See log for details." + ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -466,6 +457,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class ResultStream: """Class that will stream the result when available.""" + last_used: float = field(default_factory=monotonic, init=False) + # Streaming/conversion properties token: str extension: str @@ -489,11 +482,6 @@ class ResultStream: """Get the future that returns the cache.""" return asyncio.Future() - @callback - def async_set_message_cache(self, cache: TTSCache) -> None: - """Set cache containing message audio to be streamed.""" - self._result_cache.set_result(cache) - @callback def async_set_message(self, message: str) -> None: """Set message to be generated.""" @@ -507,12 +495,26 @@ class ResultStream: ) ) + @callback + def async_set_message_stream(self, message_stream: AsyncGenerator[str]) -> None: + """Set a stream that will generate the message.""" + self._result_cache.set_result( + self._manager.async_cache_message_stream_in_memory( + engine=self.engine, + message_stream=message_stream, + language=self.language, + options=self.options, + ) + ) + async def async_stream_result(self) -> AsyncGenerator[bytes]: """Get the stream of this result.""" cache = await self._result_cache async for chunk in cache.async_stream_data(): yield chunk + self.last_used = monotonic() + def _hash_options(options: dict) -> str: """Hashes an options dictionary.""" @@ -524,13 +526,25 @@ def _hash_options(options: dict) -> str: return opts_hash.hexdigest() -class MemcacheCleanup: +class HasLastUsed(Protocol): + """Protocol for objects that have a last_used attribute.""" + + last_used: float + + +T = TypeVar("T", bound=HasLastUsed) + + +class DictCleaning(Generic[T]): """Helper to clean up the stale sessions.""" unsub: CALLBACK_TYPE | None = None def __init__( - self, hass: HomeAssistant, maxage: float, memcache: dict[str, TTSCache] + self, + hass: HomeAssistant, + maxage: float, + memcache: MutableMapping[str, T], ) -> None: """Initialize the cleanup.""" self.hass = hass @@ -597,8 +611,9 @@ class SpeechManager: self.file_cache: dict[str, str] = {} self.mem_cache: dict[str, TTSCache] = {} self.token_to_stream: dict[str, ResultStream] = {} - self.memcache_cleanup = MemcacheCleanup( - hass, memory_cache_maxage, self.mem_cache + self.memcache_cleanup = DictCleaning(hass, memory_cache_maxage, self.mem_cache) + self.token_to_stream_cleanup = DictCleaning( + hass, memory_cache_maxage, self.token_to_stream ) def _init_cache(self) -> dict[str, str]: @@ -688,11 +703,21 @@ class SpeechManager: return language, merged_options + @callback + def async_get_result_stream( + self, + token: str, + ) -> ResultStream | None: + """Return a result stream given a token.""" + stream = self.token_to_stream.get(token, None) + if stream: + stream.last_used = monotonic() + return stream + @callback def async_create_result_stream( self, engine: str, - message: str | None = None, use_file_cache: bool | None = None, language: str | None = None, options: dict | None = None, @@ -719,65 +744,61 @@ class SpeechManager: _manager=self, ) self.token_to_stream[token] = result_stream + self.token_to_stream_cleanup.schedule() + return result_stream - if message is None: - return result_stream + @callback + def async_cache_message_stream_in_memory( + self, + engine: str, + message_stream: AsyncGenerator[str], + language: str, + options: dict, + ) -> TTSCache: + """Make sure a message stream will be cached in memory and returns cache object. - # We added this method as an alternative to stream.async_set_message - # to avoid the options being processed twice - result_stream.async_set_message_cache( - self._async_ensure_cached_in_memory( - engine=engine, - engine_instance=engine_instance, - message=message, - use_file_cache=use_file_cache, - language=language, - options=options, - ) + Requires options, language to be processed. + """ + if (engine_instance := get_engine_instance(self.hass, engine)) is None: + raise HomeAssistantError(f"Provider {engine} not found") + + cache_key = ulid_util.ulid_now() + extension = options.get(ATTR_PREFERRED_FORMAT, _DEFAULT_FORMAT) + data_gen = self._async_generate_tts_audio( + engine_instance, message_stream, language, options ) - return result_stream + cache = TTSCache( + cache_key=cache_key, + extension=extension, + data_gen=data_gen, + ) + self.mem_cache[cache_key] = cache + self.hass.async_create_background_task( + self._load_data_into_cache( + cache, engine_instance, "[Streaming TTS]", False, language, options + ), + f"tts_load_data_into_cache_{engine_instance.name}", + ) + self.memcache_cleanup.schedule() + return cache @callback def async_cache_message_in_memory( self, engine: str, message: str, - use_file_cache: bool | None = None, - language: str | None = None, - options: dict | None = None, - ) -> TTSCache: - """Make sure a message is cached in memory and returns cache key.""" - if (engine_instance := get_engine_instance(self.hass, engine)) is None: - raise HomeAssistantError(f"Provider {engine} not found") - - language, options = self.process_options(engine_instance, language, options) - if use_file_cache is None: - use_file_cache = self.use_file_cache - - return self._async_ensure_cached_in_memory( - engine=engine, - engine_instance=engine_instance, - message=message, - use_file_cache=use_file_cache, - language=language, - options=options, - ) - - @callback - def _async_ensure_cached_in_memory( - self, - engine: str, - engine_instance: TextToSpeechEntity | Provider, - message: str, use_file_cache: bool, language: str, options: dict, ) -> TTSCache: - """Ensure a message is cached. + """Make sure a message will be cached in memory and returns cache object. Requires options, language to be processed. """ + if (engine_instance := get_engine_instance(self.hass, engine)) is None: + raise HomeAssistantError(f"Provider {engine} not found") + options_key = _hash_options(options) if options else "-" msg_hash = hashlib.sha1(bytes(message, "utf-8")).hexdigest() cache_key = KEY_PATTERN.format( @@ -798,9 +819,13 @@ class SpeechManager: store_to_disk = False else: _LOGGER.debug("Generating audio for %s", message[0:32]) + + async def message_stream() -> AsyncGenerator[str]: + yield message + extension = options.get(ATTR_PREFERRED_FORMAT, _DEFAULT_FORMAT) data_gen = self._async_generate_tts_audio( - engine_instance, message, language, options + engine_instance, message_stream(), language, options ) cache = TTSCache( @@ -808,7 +833,6 @@ class SpeechManager: extension=extension, data_gen=data_gen, ) - self.mem_cache[cache_key] = cache self.hass.async_create_background_task( self._load_data_into_cache( @@ -875,7 +899,7 @@ class SpeechManager: async def _async_generate_tts_audio( self, engine_instance: TextToSpeechEntity | Provider, - message: str, + message_stream: AsyncGenerator[str], language: str, options: dict[str, Any], ) -> AsyncGenerator[bytes]: @@ -924,6 +948,7 @@ class SpeechManager: raise HomeAssistantError("TTS engine name is not set.") if isinstance(engine_instance, Provider): + message = "".join([chunk async for chunk in message_stream]) extension, data = await engine_instance.async_get_tts_audio( message, language, options ) @@ -939,12 +964,8 @@ class SpeechManager: data_gen = make_data_generator(data) else: - - async def message_gen() -> AsyncGenerator[str]: - yield message - tts_result = await engine_instance.internal_async_stream_tts_audio( - TTSAudioRequest(language, options, message_gen()) + TTSAudioRequest(language, options, message_stream) ) extension = tts_result.extension data_gen = tts_result.data_gen @@ -1105,7 +1126,6 @@ class TextToSpeechUrlView(HomeAssistantView): try: stream = self.manager.async_create_result_stream( engine, - message, use_file_cache=use_file_cache, language=language, options=options, @@ -1114,6 +1134,8 @@ class TextToSpeechUrlView(HomeAssistantView): _LOGGER.error("Error on init tts: %s", err) return self.json({"error": err}, HTTPStatus.BAD_REQUEST) + stream.async_set_message(message) + base = get_url(self.manager.hass) url = base + stream.url @@ -1190,6 +1212,9 @@ def websocket_list_engines( if entity.platform: entity_domains.add(entity.platform.platform_name) for engine_id, provider in hass.data[DATA_TTS_MANAGER].providers.items(): + if provider.has_entity: + continue + provider_info = { "engine_id": engine_id, "name": provider.name, diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index 6f0541734d1..877ecc034d6 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -207,6 +207,7 @@ class Provider: hass: HomeAssistant | None = None name: str | None = None + has_entity: bool = False @property def default_language(self) -> str | None: diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index aa2cd6e7555..f096e082364 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -69,14 +69,20 @@ class MediaSourceOptions(TypedDict): """Media source options.""" engine: str - message: str language: str | None options: dict | None use_file_cache: bool | None +class ParsedMediaSourceId(TypedDict): + """Parsed media source ID.""" + + options: MediaSourceOptions + message: str + + @callback -def media_source_id_to_kwargs(media_source_id: str) -> MediaSourceOptions: +def parse_media_source_id(media_source_id: str) -> ParsedMediaSourceId: """Turn a media source ID into options.""" parsed = URL(media_source_id) if URL_QUERY_TTS_OPTIONS in parsed.query: @@ -94,7 +100,6 @@ def media_source_id_to_kwargs(media_source_id: str) -> MediaSourceOptions: raise Unresolvable("No message specified.") kwargs: MediaSourceOptions = { "engine": parsed.name, - "message": parsed.query["message"], "language": parsed.query.get("language"), "options": options, "use_file_cache": None, @@ -102,7 +107,7 @@ def media_source_id_to_kwargs(media_source_id: str) -> MediaSourceOptions: if "cache" in parsed.query: kwargs["use_file_cache"] = parsed.query["cache"] == "true" - return kwargs + return {"message": parsed.query["message"], "options": kwargs} class TTSMediaSource(MediaSource): @@ -118,9 +123,11 @@ class TTSMediaSource(MediaSource): async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" try: + parsed = parse_media_source_id(item.identifier) stream = self.hass.data[DATA_TTS_MANAGER].async_create_result_stream( - **media_source_id_to_kwargs(item.identifier) + **parsed["options"] ) + stream.async_set_message(parsed["message"]) except Unresolvable: raise except HomeAssistantError as err: @@ -138,13 +145,20 @@ class TTSMediaSource(MediaSource): return self._engine_item(engine, params) # Root. List providers. - children = [ - self._engine_item(engine) - for engine in self.hass.data[DATA_TTS_MANAGER].providers - ] + [ - self._engine_item(entity.entity_id) - for entity in self.hass.data[DATA_COMPONENT].entities - ] + children = sorted( + [ + self._engine_item(engine_id) + for engine_id, provider in self.hass.data[ + DATA_TTS_MANAGER + ].providers.items() + if not provider.has_entity + ] + + [ + self._engine_item(entity.entity_id) + for entity in self.hass.data[DATA_COMPONENT].entities + ], + key=lambda x: x.title, + ) return BrowseMediaSource( domain=DOMAIN, identifier=None, @@ -166,7 +180,7 @@ class TTSMediaSource(MediaSource): raise BrowseError("Unknown provider") if isinstance(engine_instance, TextToSpeechEntity): - engine_domain = engine_instance.platform.domain + engine_domain = engine_instance.platform.platform_name else: engine_domain = engine diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 83847d32fb5..c6f6bfe9776 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -14,7 +14,7 @@ } }, "scan": { - "description": "Use Smart Life app or Tuya Smart app to scan the following QR-code to complete the login.\n\nContinue to the next step once you have completed this step in the app." + "description": "Use the Smart Life app or Tuya Smart app to scan the following QR code to complete the login.\n\nContinue to the next step once you have completed this step in the app." } }, "error": { @@ -288,9 +288,9 @@ "motion_sensitivity": { "name": "Motion detection sensitivity", "state": { - "0": "Low sensitivity", - "1": "Medium sensitivity", - "2": "High sensitivity" + "0": "[%key:common::state::low%]", + "1": "[%key:common::state::medium%]", + "2": "[%key:common::state::high%]" } }, "record_mode": { @@ -321,9 +321,9 @@ "vacuum_cistern": { "name": "Water tank adjustment", "state": { - "low": "Low", + "low": "[%key:common::state::low%]", "middle": "Middle", - "high": "High", + "high": "[%key:common::state::high%]", "closed": "[%key:common::state::closed%]" } }, @@ -404,7 +404,7 @@ "humidifier_spray_mode": { "name": "Spray mode", "state": { - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "health": "Health", "sleep": "Sleep", "humidity": "Humidity", diff --git a/homeassistant/components/twilio/strings.json b/homeassistant/components/twilio/strings.json index f4b7dee707f..bfac7fa80b6 100644 --- a/homeassistant/components/twilio/strings.json +++ b/homeassistant/components/twilio/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up the Twilio Webhook", + "title": "Set up the Twilio webhook", "description": "[%key:common::config_flow::description::confirm_setup%]" } }, @@ -12,7 +12,7 @@ "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, "create_entry": { - "default": "To send events to Home Assistant, you will need to set up [webhooks with Twilio]({twilio_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + "default": "To send events to Home Assistant, you will need to set up a [webhook with Twilio]({twilio_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." } } } diff --git a/homeassistant/components/unifi/diagnostics.py b/homeassistant/components/unifi/diagnostics.py index 21174342594..49a9b678b0f 100644 --- a/homeassistant/components/unifi/diagnostics.py +++ b/homeassistant/components/unifi/diagnostics.py @@ -27,7 +27,7 @@ REDACT_DEVICES = { "x_ssh_hostkey_fingerprint", "x_vwirekey", } -REDACT_WLANS = {"bc_filter_list", "x_passphrase"} +REDACT_WLANS = {"bc_filter_list", "password", "x_passphrase"} @callback diff --git a/homeassistant/components/unifi/hub/api.py b/homeassistant/components/unifi/hub/api.py index acdd941dd15..8cfe06c1b55 100644 --- a/homeassistant/components/unifi/hub/api.py +++ b/homeassistant/components/unifi/hub/api.py @@ -3,8 +3,8 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import ssl -from types import MappingProxyType from typing import Any, Literal from aiohttp import CookieJar @@ -27,7 +27,7 @@ from ..errors import AuthenticationRequired, CannotConnect async def get_unifi_api( hass: HomeAssistant, - config: MappingProxyType[str, Any], + config: Mapping[str, Any], ) -> aiounifi.Controller: """Create a aiounifi object and verify authentication.""" ssl_context: ssl.SSLContext | Literal[False] = False diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index a4bb6d20841..e23568480ca 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.5.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.6.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index b8619b1fe39..e5829882200 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -4,19 +4,17 @@ from __future__ import annotations from pyuptimerobot import UptimeRobot -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.aiohttp_client import async_get_clientsession -from .const import DOMAIN, PLATFORMS -from .coordinator import UptimeRobotDataUpdateCoordinator +from .const import PLATFORMS +from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: UptimeRobotConfigEntry) -> bool: """Set up UptimeRobot from a config entry.""" - hass.data.setdefault(DOMAIN, {}) key: str = entry.data[CONF_API_KEY] if key.startswith(("ur", "m")): raise ConfigEntryAuthFailed( @@ -24,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) uptime_robot_api = UptimeRobot(key, async_get_clientsession(hass)) - hass.data[DOMAIN][entry.entry_id] = coordinator = UptimeRobotDataUpdateCoordinator( + coordinator = UptimeRobotDataUpdateCoordinator( hass, entry, api=uptime_robot_api, @@ -32,15 +30,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: UptimeRobotConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - 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/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index 73f9400c013..e8803b6ad89 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -7,22 +7,23 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import UptimeRobotDataUpdateCoordinator +from .coordinator import UptimeRobotConfigEntry from .entity import UptimeRobotEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UptimeRobotConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot binary_sensors.""" - coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( UptimeRobotBinarySensor( coordinator, @@ -42,4 +43,4 @@ class UptimeRobotBinarySensor(UptimeRobotEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return True if the entity is on.""" - return self.monitor_available + return bool(self.monitor.status == 2) diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py index ffe3c3e4563..5fc165c0f27 100644 --- a/homeassistant/components/uptimerobot/config_flow.py +++ b/homeassistant/components/uptimerobot/config_flow.py @@ -44,11 +44,9 @@ class UptimeRobotConfigFlow(ConfigFlow, domain=DOMAIN): try: response = await uptime_robot_api.async_get_account_details() - except UptimeRobotAuthenticationException as exception: - LOGGER.error(exception) + except UptimeRobotAuthenticationException: errors["base"] = "invalid_api_key" - except UptimeRobotException as exception: - LOGGER.error(exception) + except UptimeRobotException: errors["base"] = "cannot_connect" except Exception as exception: # noqa: BLE001 LOGGER.exception(exception) diff --git a/homeassistant/components/uptimerobot/coordinator.py b/homeassistant/components/uptimerobot/coordinator.py index fbadc237965..2f6225fa498 100644 --- a/homeassistant/components/uptimerobot/coordinator.py +++ b/homeassistant/components/uptimerobot/coordinator.py @@ -17,16 +17,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import API_ATTR_OK, COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER +type UptimeRobotConfigEntry = ConfigEntry[UptimeRobotDataUpdateCoordinator] + class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMonitor]]): """Data update coordinator for UptimeRobot.""" - config_entry: ConfigEntry + config_entry: UptimeRobotConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UptimeRobotConfigEntry, api: UptimeRobot, ) -> None: """Initialize coordinator.""" diff --git a/homeassistant/components/uptimerobot/diagnostics.py b/homeassistant/components/uptimerobot/diagnostics.py index 23c65373045..c3c2acbfbf1 100644 --- a/homeassistant/components/uptimerobot/diagnostics.py +++ b/homeassistant/components/uptimerobot/diagnostics.py @@ -6,19 +6,17 @@ from typing import Any from pyuptimerobot import UptimeRobotException -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import UptimeRobotDataUpdateCoordinator +from .coordinator import UptimeRobotConfigEntry async def async_get_config_entry_diagnostics( hass: HomeAssistant, - entry: ConfigEntry, + entry: UptimeRobotConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data account: dict[str, Any] | str | None = None try: response = await coordinator.api.async_get_account_details() diff --git a/homeassistant/components/uptimerobot/entity.py b/homeassistant/components/uptimerobot/entity.py index 71f7a2f1c00..a27d4a6f80e 100644 --- a/homeassistant/components/uptimerobot/entity.py +++ b/homeassistant/components/uptimerobot/entity.py @@ -59,8 +59,3 @@ class UptimeRobotEntity(CoordinatorEntity[UptimeRobotDataUpdateCoordinator]): ), self._monitor, ) - - @property - def monitor_available(self) -> bool: - """Returtn if the monitor is available.""" - return bool(self.monitor.status == 2) diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index 67e57f46986..6fe8083ffc6 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/uptimerobot", "iot_class": "cloud_polling", "loggers": ["pyuptimerobot"], + "quality_scale": "bronze", "requirements": ["pyuptimerobot==22.2.0"] } diff --git a/homeassistant/components/uptimerobot/quality_scale.yaml b/homeassistant/components/uptimerobot/quality_scale.yaml new file mode 100644 index 00000000000..43076320b8f --- /dev/null +++ b/homeassistant/components/uptimerobot/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: no actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: no 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: todo + comment: we should not swallow the exception in switch.py + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: + status: todo + comment: Change the type of the coordinator data to be a dict[str, UptimeRobotMonitor] so we can just do a dict look up instead of iterating over the whole list + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: device not discoverable + discovery: + status: exempt + comment: device not discoverable + docs-data-update: done + docs-examples: done + docs-known-limitations: + status: exempt + comment: no known limitations, yet + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: todo + comment: create entities on runtime instead of triggering a reload + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: no known use case + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: todo + comment: handle API key change/update + repair-issues: + status: exempt + comment: no known use cases for repair issues or flows, yet + stale-devices: + status: todo + comment: We should remove the config entry from the device rather than remove the device + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: + status: todo + comment: Requirement 'pyuptimerobot==22.2.0' appears untyped diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index 724c3075a3b..3ed97d17508 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -7,13 +7,11 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import UptimeRobotDataUpdateCoordinator +from .coordinator import UptimeRobotConfigEntry from .entity import UptimeRobotEntity SENSORS_INFO = { @@ -24,14 +22,17 @@ SENSORS_INFO = { 9: "down", } +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UptimeRobotConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot sensors.""" - coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( UptimeRobotSensor( coordinator, diff --git a/homeassistant/components/uptimerobot/strings.json b/homeassistant/components/uptimerobot/strings.json index 588dc3ebf5c..6bcd1554b16 100644 --- a/homeassistant/components/uptimerobot/strings.json +++ b/homeassistant/components/uptimerobot/strings.json @@ -2,16 +2,20 @@ "config": { "step": { "user": { - "description": "You need to supply the 'main' API key from UptimeRobot", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The 'main' API key for your UptimeRobot account" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "You need to supply a new 'main' API key from UptimeRobot", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::uptimerobot::config::step::user::data_description::api_key%]" } } }, diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index 31401ac7eb4..9b25570393a 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -11,22 +11,24 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import API_ATTR_OK, DOMAIN, LOGGER -from .coordinator import UptimeRobotDataUpdateCoordinator +from .const import API_ATTR_OK, LOGGER +from .coordinator import UptimeRobotConfigEntry from .entity import UptimeRobotEntity +# Limit the number of parallel updates to 1 +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UptimeRobotConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot switches.""" - coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( UptimeRobotSwitch( coordinator, diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 425dfa2c3fd..cda538386c1 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -577,10 +577,10 @@ class UtilityMeterSensor(RestoreSensor): async def _async_reset_meter(self, event): """Reset the utility meter status.""" - await self._program_reset() - await self.async_reset_meter(self._tariff_entity) + await self._program_reset() + async def async_reset_meter(self, entity_id): """Reset meter.""" if self._tariff_entity is not None and self._tariff_entity != entity_id: diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 1efaf87e748..f9e7a2844cd 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -23,7 +23,7 @@ "state": { "cleaning": "Cleaning", "docked": "Docked", - "error": "Error", + "error": "[%key:common::state::error%]", "idle": "[%key:common::state::idle%]", "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index f00206826d3..2a074cf2015 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -152,8 +152,8 @@ "selector": { "profile": { "options": { - "home": "Home", - "away": "Away", + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]", "boost": "Boost", "fireplace": "Fireplace", "extra": "Extra" diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json index b86ec371b34..39dc297fe7d 100644 --- a/homeassistant/components/valve/strings.json +++ b/homeassistant/components/valve/strings.json @@ -5,10 +5,10 @@ "name": "[%key:component::valve::title%]", "state": { "open": "[%key:common::state::open%]", - "opening": "Opening", + "opening": "[%key:common::state::opening%]", "closed": "[%key:common::state::closed%]", - "closing": "Closing", - "stopped": "Stopped" + "closing": "[%key:common::state::closing%]", + "stopped": "[%key:common::state::stopped%]" }, "state_attributes": { "current_position": { diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 1cb540b22ec..2c05ae0301b 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -14,7 +14,7 @@ "velbus-protocol" ], "quality_scale": "bronze", - "requirements": ["velbus-aio==2025.3.1"], + "requirements": ["velbus-aio==2025.4.2"], "usb": [ { "vid": "10CF", diff --git a/homeassistant/components/venstar/strings.json b/homeassistant/components/venstar/strings.json index fdc75162651..1d916d0b8f6 100644 --- a/homeassistant/components/venstar/strings.json +++ b/homeassistant/components/venstar/strings.json @@ -32,7 +32,7 @@ "name": "Filter usage" }, "schedule_part": { - "name": "Schedule Part", + "name": "Schedule part", "state": { "morning": "Morning", "day": "Day", @@ -44,7 +44,7 @@ "active_stage": { "name": "Active stage", "state": { - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "first_stage": "First stage", "second_stage": "Second stage" } diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index 9b63bf3e614..b74ebc4f00e 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -65,6 +65,11 @@ "name": "Mist level" } }, + "switch": { + "display": { + "name": "Display" + } + }, "select": { "night_light_level": { "name": "Night light level", @@ -81,7 +86,7 @@ "state_attributes": { "preset_mode": { "state": { - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "sleep": "Sleep", "advanced_sleep": "Advanced sleep", "pet": "Pet", diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index 3e8deedb4ad..06fbd3606bd 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -14,10 +14,11 @@ from homeassistant.components.switch import ( ) 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 AddConfigEntryEntitiesCallback -from .common import is_outlet, is_wall_switch +from .common import is_outlet, is_wall_switch, rgetattr from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -45,6 +46,14 @@ SENSOR_DESCRIPTIONS: Final[tuple[VeSyncSwitchEntityDescription, ...]] = ( on_fn=lambda device: device.turn_on(), off_fn=lambda device: device.turn_off(), ), + VeSyncSwitchEntityDescription( + key="display", + is_on=lambda device: device.display_state, + exists_fn=lambda device: rgetattr(device, "display_state") is not None, + translation_key="display", + on_fn=lambda device: device.turn_on_display(), + off_fn=lambda device: device.turn_off_display(), + ), ) @@ -111,10 +120,14 @@ class VeSyncSwitchEntity(SwitchEntity, VeSyncBaseEntity): def turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - if self.entity_description.off_fn(self.device): - self.schedule_update_ha_state() + if not self.entity_description.off_fn(self.device): + raise HomeAssistantError("An error occurred while turning off.") + + self.schedule_update_ha_state() def turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - if self.entity_description.on_fn(self.device): - self.schedule_update_ha_state() + if not self.entity_description.on_fn(self.device): + raise HomeAssistantError("An error occurred while turning on.") + + self.schedule_update_ha_state() diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 902dfd18d30..a032b1fbbcb 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -112,6 +112,11 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getOneTimeCharge(), ), + ViCareBinarySensorEntityDescription( + key="device_error", + device_class=BinarySensorDeviceClass.PROBLEM, + value_getter=lambda api: len(api.getDeviceErrors()) > 0, + ), ) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 04049f026bd..dd8d93e609a 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "{name} ({host})", + "flow_title": "{name}", "step": { "user": { "description": "Set up ViCare integration. To generate client ID go to https://app.developer.viessmann.com", @@ -11,8 +11,8 @@ "heating_type": "Heating type" }, "data_description": { - "username": "The email address to login to your ViCare account.", - "password": "The password to login to your ViCare account.", + "username": "The email address to log in to your ViCare account.", + "password": "The password to log in to your ViCare account.", "client_id": "The ID of the API client created in the Viessmann developer portal.", "heating_type": "Allows to overrule the device auto detection." } @@ -362,9 +362,9 @@ "ess_state": { "name": "Battery state", "state": { - "charge": "Charging", - "discharge": "Discharging", - "standby": "Standby" + "charge": "[%key:common::state::charging%]", + "discharge": "[%key:common::state::discharging%]", + "standby": "[%key:common::state::standby%]" } }, "ess_discharge_today": { @@ -412,7 +412,7 @@ "photovoltaic_status": { "name": "PV state", "state": { - "ready": "Standby", + "ready": "[%key:common::state::standby%]", "production": "Producing" } }, diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index b4ba5663ac2..5efc33ca882 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -4,18 +4,21 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platfor from homeassistant.core import HomeAssistant from .coordinator import VodafoneConfigEntry, VodafoneStationRouter +from .utils import async_client_session PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool: """Set up Vodafone Station platform.""" + session = await async_client_session(hass) coordinator = VodafoneStationRouter( hass, entry.data[CONF_HOST], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], entry, + session, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index 6641f5f5711..b69078b8ce6 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant, callback from .const import _LOGGER, DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN from .coordinator import VodafoneConfigEntry +from .utils import async_client_session def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: @@ -38,8 +39,9 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: """Validate the user input allows us to connect.""" + session = await async_client_session(hass) api = VodafoneStationSercommApi( - data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD] + data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD], session ) try: @@ -156,8 +158,6 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} - errors = {} - try: await validate_input(self.hass, user_input) except aiovodafone_exceptions.AlreadyLogged: diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index cee66bd2e7c..846d4b042c0 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from json.decoder import JSONDecodeError from typing import Any, cast +from aiohttp import ClientSession from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions from homeassistant.components.device_tracker import DEFAULT_CONSIDER_HOME @@ -53,11 +54,12 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): username: str, password: str, config_entry: VodafoneConfigEntry, + session: ClientSession, ) -> None: """Initialize the scanner.""" self._host = host - self.api = VodafoneStationSercommApi(host, username, password) + self.api = VodafoneStationSercommApi(host, username, password, session) # Last resort as no MAC or S/N can be retrieved via API self._id = config_entry.unique_id diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 29cb3c070ab..4c33cf1a4a5 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "quality_scale": "silver", - "requirements": ["aiovodafone==0.6.1"] + "quality_scale": "platinum", + "requirements": ["aiovodafone==0.10.0"] } diff --git a/homeassistant/components/vodafone_station/utils.py b/homeassistant/components/vodafone_station/utils.py new file mode 100644 index 00000000000..4f900412faf --- /dev/null +++ b/homeassistant/components/vodafone_station/utils.py @@ -0,0 +1,13 @@ +"""Utils for Vodafone Station.""" + +from aiohttp import ClientSession, CookieJar + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + + +async def async_client_session(hass: HomeAssistant) -> ClientSession: + """Return a new aiohttp session.""" + return aiohttp_client.async_create_clientsession( + hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) + ) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 2c0a3b9641a..7b34d7a11ba 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -51,9 +51,9 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) _PIPELINE_TIMEOUT_SEC: Final = 30 +_HANGUP_SEC: Final = 0.5 _ANNOUNCEMENT_BEFORE_DELAY: Final = 0.5 _ANNOUNCEMENT_AFTER_DELAY: Final = 1.0 -_ANNOUNCEMENT_HANGUP_SEC: Final = 0.5 _ANNOUNCEMENT_RING_TIMEOUT: Final = 30 @@ -101,6 +101,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol entity_description = AssistSatelliteEntityDescription(key="assist_satellite") _attr_translation_key = "assist_satellite" _attr_name = None + _attr_icon = "mdi:phone-classic" _attr_supported_features = ( AssistSatelliteEntityFeature.ANNOUNCE | AssistSatelliteEntityFeature.START_CONVERSATION @@ -131,9 +132,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._processing_tone_done = asyncio.Event() self._announcement: AssistSatelliteAnnouncement | None = None - self._announcement_future: asyncio.Future[Any] = asyncio.Future() self._announcment_start_time: float = 0.0 - self._check_announcement_ended_task: asyncio.Task | None = None + self._check_announcement_pickup_task: asyncio.Task | None = None + self._check_hangup_task: asyncio.Task | None = None + self._call_end_future: asyncio.Future[Any] = asyncio.Future() self._last_chunk_time: float | None = None self._rtp_port: int | None = None self._run_pipeline_after_announce: bool = False @@ -232,7 +234,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol translation_key="non_tts_announcement", ) - self._announcement_future = asyncio.Future() + self._call_end_future = asyncio.Future() self._run_pipeline_after_announce = run_pipeline_after if self._rtp_port is None: @@ -273,53 +275,77 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol rtp_port=self._rtp_port, ) - # Check if caller hung up or didn't pick up - self._check_announcement_ended_task = ( + # Check if caller didn't pick up + self._check_announcement_pickup_task = ( self.config_entry.async_create_background_task( self.hass, - self._check_announcement_ended(), - "voip_announcement_ended", + self._check_announcement_pickup(), + "voip_announcement_pickup", ) ) try: - await self._announcement_future + await self._call_end_future except TimeoutError: # Stop ringing + _LOGGER.debug("Caller did not pick up in time") sip_protocol.cancel_call(call_info) raise - async def _check_announcement_ended(self) -> None: + async def _check_announcement_pickup(self) -> None: """Continuously checks if an audio chunk was received within a time limit. - If not, the caller is presumed to have hung up and the announcement is ended. + If not, the caller is presumed to have not picked up the phone and the announcement is ended. """ - while self._announcement is not None: + while True: current_time = time.monotonic() if (self._last_chunk_time is None) and ( (current_time - self._announcment_start_time) > _ANNOUNCEMENT_RING_TIMEOUT ): # Ring timeout + _LOGGER.debug("Ring timeout") self._announcement = None - self._check_announcement_ended_task = None - self._announcement_future.set_exception( + self._check_announcement_pickup_task = None + self._call_end_future.set_exception( TimeoutError("User did not pick up in time") ) _LOGGER.debug("Timed out waiting for the user to pick up the phone") break - - if (self._last_chunk_time is not None) and ( - (current_time - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC - ): - # Caller hung up - self._announcement = None - self._announcement_future.set_result(None) - self._check_announcement_ended_task = None - _LOGGER.debug("Announcement ended") + if self._last_chunk_time is not None: + _LOGGER.debug("Picked up the phone") + self._check_announcement_pickup_task = None break - await asyncio.sleep(_ANNOUNCEMENT_HANGUP_SEC / 2) + await asyncio.sleep(_HANGUP_SEC / 2) + + async def _check_hangup(self) -> None: + """Continuously checks if an audio chunk was received within a time limit. + + If not, the caller is presumed to have hung up and the call is ended. + """ + try: + while True: + current_time = time.monotonic() + if (self._last_chunk_time is not None) and ( + (current_time - self._last_chunk_time) > _HANGUP_SEC + ): + # Caller hung up + _LOGGER.debug("Hang up") + self._announcement = None + if self._run_pipeline_task is not None: + _LOGGER.debug("Cancelling running pipeline") + self._run_pipeline_task.cancel() + self._call_end_future.set_result(None) + self.disconnect() + break + + await asyncio.sleep(_HANGUP_SEC / 2) + except asyncio.CancelledError: + # Don't swallow cancellation + if (current_task := asyncio.current_task()) and current_task.cancelling(): + raise + _LOGGER.debug("Check hangup cancelled") async def async_start_conversation( self, start_announcement: AssistSatelliteAnnouncement @@ -331,6 +357,24 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol # VoIP # ------------------------------------------------------------------------- + def disconnect(self): + """Server disconnected.""" + super().disconnect() + if self._check_hangup_task is not None: + self._check_hangup_task.cancel() + self._check_hangup_task = None + + def connection_made(self, transport): + """Server is ready.""" + super().connection_made(transport) + self._last_chunk_time = time.monotonic() + # Check if caller hung up + self._check_hangup_task = self.config_entry.async_create_background_task( + self.hass, + self._check_hangup(), + "voip_hangup", + ) + def on_chunk(self, audio_bytes: bytes) -> None: """Handle raw audio chunk.""" self._last_chunk_time = time.monotonic() @@ -367,13 +411,22 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self.voip_device.set_is_active(True) async def stt_stream(): + retry: bool = True while True: - async with asyncio.timeout(self._audio_chunk_timeout): - chunk = await self._audio_queue.get() - if not chunk: - break + try: + async with asyncio.timeout(self._audio_chunk_timeout): + chunk = await self._audio_queue.get() + if not chunk: + _LOGGER.debug("STT stream got None") + break yield chunk + except TimeoutError: + _LOGGER.debug("STT Stream timed out") + if not retry: + _LOGGER.debug("No more retries, ending STT stream") + break + retry = False # Play listening tone at the start of each cycle await self._play_tone(Tones.LISTENING, silence_before=0.2) @@ -384,6 +437,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol ) if self._pipeline_had_error: + _LOGGER.debug("Pipeline error") self._pipeline_had_error = False await self._play_tone(Tones.ERROR) else: @@ -393,7 +447,14 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol # length of the TTS audio. await self._tts_done.wait() except TimeoutError: + # This shouldn't happen anymore, we are detecting hang ups with a separate task + _LOGGER.exception("Timeout error") self.disconnect() # caller hung up + except asyncio.CancelledError: + _LOGGER.debug("Pipeline cancelled") + # Don't swallow cancellation + if (current_task := asyncio.current_task()) and current_task.cancelling(): + raise finally: # Stop audio stream await self._audio_queue.put(None) @@ -408,10 +469,18 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol """Play an announcement once.""" _LOGGER.debug("Playing announcement") - try: - await asyncio.sleep(_ANNOUNCEMENT_BEFORE_DELAY) - await self._send_tts(announcement.original_media_id, wait_for_tone=False) + if announcement.tts_token is None: + _LOGGER.error("Only TTS announcements are supported") + return + await asyncio.sleep(_ANNOUNCEMENT_BEFORE_DELAY) + stream = tts.async_get_stream(self.hass, announcement.tts_token) + if stream is None: + _LOGGER.error("TTS stream no longer available") + return + + try: + await self._send_tts(stream, wait_for_tone=False) if not self._run_pipeline_after_announce: # Delay before looping announcement await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) @@ -424,8 +493,8 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol if self._run_pipeline_after_announce: # Clear announcement to allow pipeline to run + _LOGGER.debug("Clearing announcement") self._announcement = None - self._announcement_future.set_result(None) def _clear_audio_queue(self) -> None: """Ensure audio queue is empty.""" @@ -442,34 +511,41 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol ) elif event.type == PipelineEventType.TTS_END: # Send TTS audio to caller over RTP - if event.data and (tts_output := event.data["tts_output"]): - media_id = tts_output["media_id"] + if ( + event.data + and (tts_output := event.data["tts_output"]) + and (stream := tts.async_get_stream(self.hass, tts_output["token"])) + ): self.config_entry.async_create_background_task( self.hass, - self._send_tts(media_id), + self._send_tts(tts_stream=stream), "voip_pipeline_tts", ) else: # Empty TTS response + _LOGGER.debug("Empty TTS response") self._tts_done.set() elif event.type == PipelineEventType.ERROR: # Play error tone instead of wait for TTS when pipeline is finished. self._pipeline_had_error = True _LOGGER.warning(event) - async def _send_tts(self, media_id: str, wait_for_tone: bool = True) -> None: + async def _send_tts( + self, + tts_stream: tts.ResultStream, + wait_for_tone: bool = True, + ) -> None: """Send TTS audio to caller via RTP.""" try: if self.transport is None: return # not connected - extension, data = await tts.async_get_media_source_audio( - self.hass, - media_id, - ) + data = b"".join([chunk async for chunk in tts_stream.async_stream_result()]) - if extension != "wav": - raise ValueError(f"Only WAV audio can be streamed, got {extension}") + if tts_stream.extension != "wav": + raise ValueError( + f"Only TTS WAV audio can be streamed, got {tts_stream.extension}" + ) if wait_for_tone and ((self._tones & Tones.PROCESSING) == Tones.PROCESSING): # Don't overlap TTS and processing beep diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index dfd397fde14..59e54bfefea 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -1,12 +1,12 @@ { "domain": "voip", "name": "Voice over IP", - "codeowners": ["@balloob", "@synesthesiam"], + "codeowners": ["@balloob", "@synesthesiam", "@jaminh"], "config_flow": true, "dependencies": ["assist_pipeline", "assist_satellite", "intent", "network"], "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "loggers": ["voip_utils"], "quality_scale": "internal", - "requirements": ["voip-utils==0.3.1"] + "requirements": ["voip-utils==0.3.2"] } diff --git a/homeassistant/components/wallbox/manifest.json b/homeassistant/components/wallbox/manifest.json index d217a018303..cda1f0ced3d 100644 --- a/homeassistant/components/wallbox/manifest.json +++ b/homeassistant/components/wallbox/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wallbox", "iot_class": "cloud_polling", "loggers": ["wallbox"], - "requirements": ["wallbox==0.8.0"] + "requirements": ["wallbox==0.9.0"] } diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 07e132a0b5b..9cc3a84c3cd 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -14,8 +14,8 @@ "eco": "Eco", "electric": "Electric", "gas": "Gas", - "high_demand": "High Demand", - "heat_pump": "Heat Pump", + "high_demand": "High demand", + "heat_pump": "Heat pump", "performance": "Performance" }, "state_attributes": { diff --git a/homeassistant/components/watergate/__init__.py b/homeassistant/components/watergate/__init__.py index fd591215d8b..4f075a57228 100644 --- a/homeassistant/components/watergate/__init__.py +++ b/homeassistant/components/watergate/__init__.py @@ -15,6 +15,7 @@ from homeassistant.components.webhook import ( Response, async_generate_url, async_register, + async_unregister, ) from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant @@ -75,7 +76,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> bool: """Unload a config entry.""" webhook_id = entry.data[CONF_WEBHOOK_ID] - hass.components.webhook.async_unregister(webhook_id) + async_unregister(hass, webhook_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 4a360b4a43c..ddcdd4f1cf8 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -59,7 +59,11 @@ from homeassistant.loader import ( async_get_integration_descriptions, async_get_integrations, ) -from homeassistant.setup import async_get_loaded_integrations, async_get_setup_timings +from homeassistant.setup import ( + async_get_loaded_integrations, + async_get_setup_timings, + async_wait_component, +) from homeassistant.util.json import format_unserializable_data from . import const, decorators, messages @@ -98,6 +102,7 @@ def async_register_commands( async_reg(hass, handle_subscribe_entities) async_reg(hass, handle_supported_features) async_reg(hass, handle_integration_descriptions) + async_reg(hass, handle_integration_wait) def pong_message(iden: int) -> dict[str, Any]: @@ -923,3 +928,21 @@ async def handle_integration_descriptions( ) -> None: """Get metadata for all brands and integrations.""" connection.send_result(msg["id"], await async_get_integration_descriptions(hass)) + + +@decorators.websocket_command( + { + vol.Required("type"): "integration/wait", + vol.Required("domain"): str, + } +) +@decorators.async_response +async def handle_integration_wait( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle wait for integration command.""" + + domain: str = msg["domain"] + connection.send_result( + msg["id"], {"integration_loaded": await async_wait_component(hass, domain)} + ) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index ebca497193b..4250da149ad 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -14,7 +14,7 @@ from aiohttp import WSMsgType, web from aiohttp.http_websocket import WebSocketWriter from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later @@ -73,6 +73,7 @@ class WebSocketHandler: "_authenticated", "_closing", "_connection", + "_debug", "_handle_task", "_hass", "_logger", @@ -107,6 +108,12 @@ class WebSocketHandler: self._message_queue: deque[bytes] = deque() self._ready_future: asyncio.Future[int] | None = None self._release_ready_queue_size: int = 0 + self._async_logging_changed() + + @callback + def _async_logging_changed(self, event: Event | None = None) -> None: + """Handle logging change.""" + self._debug = self._logger.isEnabledFor(logging.DEBUG) def __repr__(self) -> str: """Return the representation.""" @@ -137,7 +144,6 @@ class WebSocketHandler: logger = self._logger wsock = self._wsock loop = self._loop - is_debug_log_enabled = partial(logger.isEnabledFor, logging.DEBUG) debug = logger.debug can_coalesce = connection.can_coalesce ready_message_count = len(message_queue) @@ -157,14 +163,14 @@ class WebSocketHandler: if not can_coalesce or ready_message_count == 1: message = message_queue.popleft() - if is_debug_log_enabled(): + if self._debug: debug("%s: Sending %s", self.description, message) await send_bytes_text(message) continue coalesced_messages = b"".join((b"[", b",".join(message_queue), b"]")) message_queue.clear() - if is_debug_log_enabled(): + if self._debug: debug("%s: Sending %s", self.description, coalesced_messages) await send_bytes_text(coalesced_messages) except asyncio.CancelledError: @@ -325,6 +331,9 @@ class WebSocketHandler: unsub_stop = hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, self._async_handle_hass_stop ) + cancel_logging_listener = hass.bus.async_listen( + EVENT_LOGGING_CHANGED, self._async_logging_changed + ) writer = wsock._writer # noqa: SLF001 if TYPE_CHECKING: @@ -354,6 +363,7 @@ class WebSocketHandler: "%s: Unexpected error inside websocket API", self.description ) finally: + cancel_logging_listener() unsub_stop() self._cancel_peak_checker() @@ -401,7 +411,7 @@ class WebSocketHandler: except ValueError as err: raise Disconnect("Received invalid JSON during auth phase") from err - if self._logger.isEnabledFor(logging.DEBUG): + if self._debug: self._logger.debug("%s: Received %s", self.description, auth_msg_data) connection = await auth.async_handle(auth_msg_data) # As the webserver is now started before the start @@ -463,7 +473,6 @@ class WebSocketHandler: wsock = self._wsock async_handle_str = connection.async_handle async_handle_binary = connection.async_handle_binary - _debug_enabled = partial(self._logger.isEnabledFor, logging.DEBUG) # Command phase while not wsock.closed: @@ -496,7 +505,7 @@ class WebSocketHandler: except ValueError as ex: raise Disconnect("Received invalid JSON.") from ex - if _debug_enabled(): + if self._debug: self._logger.debug( "%s: Received %s", self.description, command_msg_data ) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 0a8200c5700..6ae7de2c4b7 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -207,7 +207,7 @@ def _state_diff_event( additions[COMPRESSED_STATE_STATE] = new_state.state if old_state.last_changed != new_state.last_changed: additions[COMPRESSED_STATE_LAST_CHANGED] = new_state.last_changed_timestamp - elif old_state.last_updated != new_state.last_updated: + elif old_state.last_updated_timestamp != new_state.last_updated_timestamp: additions[COMPRESSED_STATE_LAST_UPDATED] = new_state.last_updated_timestamp if old_state_context.parent_id != new_state_context.parent_id: additions[COMPRESSED_STATE_CONTEXT] = {"parent_id": new_state_context.parent_id} diff --git a/homeassistant/components/weheat/const.py b/homeassistant/components/weheat/const.py index ee9b77281e6..cd521afd2ea 100644 --- a/homeassistant/components/weheat/const.py +++ b/homeassistant/components/weheat/const.py @@ -25,3 +25,4 @@ LOGGER: Logger = getLogger(__package__) DISPLAY_PRECISION_WATTS = 0 DISPLAY_PRECISION_COP = 1 DISPLAY_PRECISION_WATER_TEMP = 1 +DISPLAY_PRECISION_FLOW = 1 diff --git a/homeassistant/components/weheat/icons.json b/homeassistant/components/weheat/icons.json index e7f54b478c6..c0955cd051d 100644 --- a/homeassistant/components/weheat/icons.json +++ b/homeassistant/components/weheat/icons.json @@ -42,6 +42,12 @@ "heat_pump_state": { "default": "mdi:state-machine" }, + "dhw_flow_volume": { + "default": "mdi:pump" + }, + "central_heating_flow_volume": { + "default": "mdi:pump" + }, "electricity_used": { "default": "mdi:flash" }, diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index 7297c601213..cd631866fdb 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==2025.2.26"] + "requirements": ["weheat==2025.4.29"] } diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index d3b758e41eb..8ff80aeac08 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfPower, UnitOfTemperature, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -24,6 +25,7 @@ from homeassistant.helpers.typing import StateType from .const import ( DISPLAY_PRECISION_COP, + DISPLAY_PRECISION_FLOW, DISPLAY_PRECISION_WATER_TEMP, DISPLAY_PRECISION_WATTS, ) @@ -161,6 +163,15 @@ SENSORS = [ native_unit_of_measurement=PERCENTAGE, value_fn=lambda status: status.compressor_percentage, ), + WeHeatSensorEntityDescription( + translation_key="central_heating_flow_volume", + key="central_heating_flow_volume", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=DISPLAY_PRECISION_FLOW, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + value_fn=lambda status: status.central_heating_flow_volume, + ), ] DHW_SENSORS = [ @@ -182,6 +193,15 @@ DHW_SENSORS = [ suggested_display_precision=DISPLAY_PRECISION_WATER_TEMP, value_fn=lambda status: status.dhw_bottom_temperature, ), + WeHeatSensorEntityDescription( + translation_key="dhw_flow_volume", + key="dhw_flow_volume", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=DISPLAY_PRECISION_FLOW, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + value_fn=lambda status: status.dhw_flow_volume, + ), ] ENERGY_SENSORS = [ diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json index 3959acad053..93a3fbaad30 100644 --- a/homeassistant/components/weheat/strings.json +++ b/homeassistant/components/weheat/strings.json @@ -86,6 +86,12 @@ "dhw_bottom_temperature": { "name": "DHW bottom temperature" }, + "dhw_flow_volume": { + "name": "DHW pump flow" + }, + "central_heating_flow_volume": { + "name": "Central heating pump flow" + }, "heat_pump_state": { "state": { "standby": "[%key:common::state::standby%]", @@ -95,7 +101,7 @@ "dhw": "Heating DHW", "legionella_prevention": "Legionella prevention", "defrosting": "Defrosting", - "self_test": "Self test", + "self_test": "Self-test", "manual_control": "Manual control" } }, diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 3ef7ac92f98..96e61dfded6 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -21,7 +21,7 @@ from homeassistant.util.async_ import gather_with_limited_concurrency from .const import DOMAIN from .coordinator import DeviceCoordinator, async_register_device -from .models import WemoConfigEntryData, WemoData, async_wemo_data +from .models import DATA_WEMO, WemoConfigEntryData, WemoData # Max number of devices to initialize at once. This limit is in place to # avoid tying up too many executor threads with WeMo device setup. @@ -117,7 +117,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a wemo config entry.""" - wemo_data = async_wemo_data(hass) + wemo_data = hass.data[DATA_WEMO] dispatcher = WemoDispatcher(entry) discovery = WemoDiscovery(hass, dispatcher, wemo_data.static_config, entry) wemo_data.config_entry_data = WemoConfigEntryData( @@ -138,7 +138,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a wemo config entry.""" _LOGGER.debug("Unloading WeMo") - wemo_data = async_wemo_data(hass) + wemo_data = hass.data[DATA_WEMO] wemo_data.config_entry_data.discovery.async_stop_discovery() @@ -161,7 +161,7 @@ async def async_wemo_dispatcher_connect( module = dispatch.__module__ # Example: "homeassistant.components.wemo.switch" platform = Platform(module.rsplit(".", 1)[1]) - dispatcher = async_wemo_data(hass).config_entry_data.dispatcher + dispatcher = hass.data[DATA_WEMO].config_entry_data.dispatcher await dispatcher.async_connect_platform(platform, dispatch) diff --git a/homeassistant/components/wemo/coordinator.py b/homeassistant/components/wemo/coordinator.py index 0aaedf598d2..6cda83f6419 100644 --- a/homeassistant/components/wemo/coordinator.py +++ b/homeassistant/components/wemo/coordinator.py @@ -29,7 +29,7 @@ from homeassistant.helpers.device_registry import CONNECTION_UPNP, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, WEMO_SUBSCRIPTION_EVENT -from .models import async_wemo_data +from .models import DATA_WEMO _LOGGER = logging.getLogger(__name__) @@ -316,9 +316,9 @@ def async_get_coordinator(hass: HomeAssistant, device_id: str) -> DeviceCoordina @callback def _async_coordinators(hass: HomeAssistant) -> dict[str, DeviceCoordinator]: - return async_wemo_data(hass).config_entry_data.device_coordinators + return hass.data[DATA_WEMO].config_entry_data.device_coordinators @callback def _async_registry(hass: HomeAssistant) -> SubscriptionRegistry: - return async_wemo_data(hass).registry + return hass.data[DATA_WEMO].registry diff --git a/homeassistant/components/wemo/models.py b/homeassistant/components/wemo/models.py index 80213c9ba33..b96cd502cd4 100644 --- a/homeassistant/components/wemo/models.py +++ b/homeassistant/components/wemo/models.py @@ -4,11 +4,11 @@ from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING import pywemo -from homeassistant.core import HomeAssistant, callback +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN @@ -16,6 +16,8 @@ if TYPE_CHECKING: # Avoid circular dependencies. from . import HostPortTuple, WemoDiscovery, WemoDispatcher from .coordinator import DeviceCoordinator +DATA_WEMO: HassKey[WemoData] = HassKey(DOMAIN) + @dataclass class WemoConfigEntryData: @@ -37,9 +39,3 @@ class WemoData: # unloaded. It's a programmer error if config_entry_data is accessed when the # config entry is not loaded config_entry_data: WemoConfigEntryData = None # type: ignore[assignment] - - -@callback -def async_wemo_data(hass: HomeAssistant) -> WemoData: - """Fetch WemoData with proper typing.""" - return cast(WemoData, hass.data[DOMAIN]) diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index cb073779379..56cdf52c649 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -13,22 +13,20 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_BRAND, CONF_BRANDS_MAP, CONF_REGIONS_MAP, DOMAIN +from .const import BRANDS_CONF_MAP, CONF_BRAND, DOMAIN, REGIONS_CONF_MAP _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR] type WhirlpoolConfigEntry = ConfigEntry[AppliancesManager] async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> bool: """Set up Whirlpool Sixth Sense from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - session = async_get_clientsession(hass) - region = CONF_REGIONS_MAP[entry.data.get(CONF_REGION, "EU")] - brand = CONF_BRANDS_MAP[entry.data.get(CONF_BRAND, "Whirlpool")] + region = REGIONS_CONF_MAP[entry.data.get(CONF_REGION, "EU")] + brand = BRANDS_CONF_MAP[entry.data.get(CONF_BRAND, "Whirlpool")] backend_selector = BackendSelector(brand, region) auth = Auth( @@ -49,8 +47,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> appliances_manager = AppliancesManager(backend_selector, auth, session) if not await appliances_manager.fetch_appliances(): - _LOGGER.error("Cannot fetch appliances") - return False + raise ConfigEntryNotReady( + translation_domain=DOMAIN, translation_key="appliances_fetch_failed" + ) await appliances_manager.connect() entry.runtime_data = appliances_manager diff --git a/homeassistant/components/whirlpool/binary_sensor.py b/homeassistant/components/whirlpool/binary_sensor.py new file mode 100644 index 00000000000..d8ec373f026 --- /dev/null +++ b/homeassistant/components/whirlpool/binary_sensor.py @@ -0,0 +1,68 @@ +"""Binary sensors for the Whirlpool Appliances integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import timedelta + +from whirlpool.appliance import Appliance + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WhirlpoolConfigEntry +from .entity import WhirlpoolEntity + +SCAN_INTERVAL = timedelta(minutes=5) + + +@dataclass(frozen=True, kw_only=True) +class WhirlpoolBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a Whirlpool binary sensor entity.""" + + value_fn: Callable[[Appliance], bool | None] + + +WASHER_DRYER_SENSORS: list[WhirlpoolBinarySensorEntityDescription] = [ + WhirlpoolBinarySensorEntityDescription( + key="door", + device_class=BinarySensorDeviceClass.DOOR, + value_fn=lambda appliance: appliance.get_door_open(), + ) +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WhirlpoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Config flow entry for Whirlpool binary sensors.""" + entities: list = [] + appliances_manager = config_entry.runtime_data + for washer_dryer in appliances_manager.washer_dryers: + entities.extend( + WhirlpoolBinarySensor(washer_dryer, description) + for description in WASHER_DRYER_SENSORS + ) + async_add_entities(entities) + + +class WhirlpoolBinarySensor(WhirlpoolEntity, BinarySensorEntity): + """A class for the Whirlpool binary sensors.""" + + def __init__( + self, appliance: Appliance, description: WhirlpoolBinarySensorEntityDescription + ) -> None: + """Initialize the washer sensor.""" + super().__init__(appliance, unique_id_suffix=f"-{description.key}") + self.entity_description: WhirlpoolBinarySensorEntityDescription = description + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self._appliance) diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index 84a2c0d52ca..75967bb81d4 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -2,13 +2,11 @@ from __future__ import annotations -import logging from typing import Any from whirlpool.aircon import Aircon, FanSpeed as AirconFanSpeed, Mode as AirconMode from homeassistant.components.climate import ( - ENTITY_ID_FORMAT, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -22,15 +20,10 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WhirlpoolConfigEntry -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - +from .entity import WhirlpoolEntity AIRCON_MODE_MAP = { AirconMode.Cool: HVACMode.COOL, @@ -70,20 +63,19 @@ async def async_setup_entry( ) -> None: """Set up entry.""" appliances_manager = config_entry.runtime_data - aircons = [AirConEntity(hass, aircon) for aircon in appliances_manager.aircons] - async_add_entities(aircons, True) + async_add_entities(AirConEntity(aircon) for aircon in appliances_manager.aircons) -class AirConEntity(ClimateEntity): +class AirConEntity(WhirlpoolEntity, ClimateEntity): """Representation of an air conditioner.""" + _appliance: Aircon + _attr_fan_modes = SUPPORTED_FAN_MODES - _attr_has_entity_name = True _attr_name = None _attr_hvac_modes = SUPPORTED_HVAC_MODES _attr_max_temp = SUPPORTED_MAX_TEMP _attr_min_temp = SUPPORTED_MIN_TEMP - _attr_should_poll = False _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE @@ -95,107 +87,81 @@ class AirConEntity(ClimateEntity): _attr_target_temperature_step = SUPPORTED_TARGET_TEMPERATURE_STEP _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, hass: HomeAssistant, aircon: Aircon) -> None: - """Initialize the entity.""" - self._aircon = aircon - self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, aircon.said, hass=hass) - self._attr_unique_id = aircon.said - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, aircon.said)}, - name=aircon.name if aircon.name is not None else aircon.said, - manufacturer="Whirlpool", - model="Sixth Sense", - ) - - async def async_added_to_hass(self) -> None: - """Register updates callback.""" - self._aircon.register_attr_callback(self.async_write_ha_state) - - async def async_will_remove_from_hass(self) -> None: - """Unregister updates callback.""" - self._aircon.unregister_attr_callback(self.async_write_ha_state) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._aircon.get_online() - @property def current_temperature(self) -> float: """Return the current temperature.""" - return self._aircon.get_current_temp() + return self._appliance.get_current_temp() @property def target_temperature(self) -> float: """Return the temperature we try to reach.""" - return self._aircon.get_temp() + return self._appliance.get_temp() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - await self._aircon.set_temp(kwargs.get(ATTR_TEMPERATURE)) + await self._appliance.set_temp(kwargs.get(ATTR_TEMPERATURE)) @property def current_humidity(self) -> int: """Return the current humidity.""" - return self._aircon.get_current_humidity() + return self._appliance.get_current_humidity() @property def target_humidity(self) -> int: """Return the humidity we try to reach.""" - return self._aircon.get_humidity() + return self._appliance.get_humidity() async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - await self._aircon.set_humidity(humidity) + await self._appliance.set_humidity(humidity) @property def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, fan.""" - if not self._aircon.get_power_on(): + if not self._appliance.get_power_on(): return HVACMode.OFF - mode: AirconMode = self._aircon.get_mode() + mode: AirconMode = self._appliance.get_mode() return AIRCON_MODE_MAP.get(mode) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set HVAC mode.""" if hvac_mode == HVACMode.OFF: - await self._aircon.set_power_on(False) + await self._appliance.set_power_on(False) return if not (mode := HVAC_MODE_TO_AIRCON_MODE.get(hvac_mode)): raise ValueError(f"Invalid hvac mode {hvac_mode}") - await self._aircon.set_mode(mode) - if not self._aircon.get_power_on(): - await self._aircon.set_power_on(True) + await self._appliance.set_mode(mode) + if not self._appliance.get_power_on(): + await self._appliance.set_power_on(True) @property def fan_mode(self) -> str: """Return the fan setting.""" - fanspeed = self._aircon.get_fanspeed() + fanspeed = self._appliance.get_fanspeed() return AIRCON_FANSPEED_MAP.get(fanspeed, FAN_OFF) async def async_set_fan_mode(self, fan_mode: str) -> None: """Set fan mode.""" if not (fanspeed := FAN_MODE_TO_AIRCON_FANSPEED.get(fan_mode)): raise ValueError(f"Invalid fan mode {fan_mode}") - await self._aircon.set_fanspeed(fanspeed) + await self._appliance.set_fanspeed(fanspeed) @property def swing_mode(self) -> str: """Return the swing setting.""" - return SWING_HORIZONTAL if self._aircon.get_h_louver_swing() else SWING_OFF + return SWING_HORIZONTAL if self._appliance.get_h_louver_swing() else SWING_OFF async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target temperature.""" - await self._aircon.set_h_louver_swing(swing_mode == SWING_HORIZONTAL) + await self._appliance.set_h_louver_swing(swing_mode == SWING_HORIZONTAL) async def async_turn_on(self) -> None: """Turn device on.""" - await self._aircon.set_power_on(True) + await self._appliance.set_power_on(True) async def async_turn_off(self) -> None: """Turn device off.""" - await self._aircon.set_power_on(False) + await self._appliance.set_power_on(False) diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index 19715643e3a..61d6883d70f 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_BRAND, CONF_BRANDS_MAP, CONF_REGIONS_MAP, DOMAIN +from .const import BRANDS_CONF_MAP, CONF_BRAND, DOMAIN, REGIONS_CONF_MAP _LOGGER = logging.getLogger(__name__) @@ -26,15 +26,15 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_REGION): vol.In(list(CONF_REGIONS_MAP)), - vol.Required(CONF_BRAND): vol.In(list(CONF_BRANDS_MAP)), + vol.Required(CONF_REGION): vol.In(list(REGIONS_CONF_MAP)), + vol.Required(CONF_BRAND): vol.In(list(BRANDS_CONF_MAP)), } ) REAUTH_SCHEMA = vol.Schema( { vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_BRAND): vol.In(list(CONF_BRANDS_MAP)), + vol.Required(CONF_BRAND): vol.In(list(BRANDS_CONF_MAP)), } ) @@ -48,8 +48,8 @@ async def authenticate( Returns the error translation key if authentication fails, or None on success. """ session = async_get_clientsession(hass) - region = CONF_REGIONS_MAP[data[CONF_REGION]] - brand = CONF_BRANDS_MAP[data[CONF_BRAND]] + region = REGIONS_CONF_MAP[data[CONF_REGION]] + brand = BRANDS_CONF_MAP[data[CONF_BRAND]] backend_selector = BackendSelector(brand, region) auth = Auth(backend_selector, data[CONF_USERNAME], data[CONF_PASSWORD], session) diff --git a/homeassistant/components/whirlpool/const.py b/homeassistant/components/whirlpool/const.py index 63a58f54c1d..163229e4a21 100644 --- a/homeassistant/components/whirlpool/const.py +++ b/homeassistant/components/whirlpool/const.py @@ -5,12 +5,12 @@ from whirlpool.backendselector import Brand, Region DOMAIN = "whirlpool" CONF_BRAND = "brand" -CONF_REGIONS_MAP = { +REGIONS_CONF_MAP = { "EU": Region.EU, "US": Region.US, } -CONF_BRANDS_MAP = { +BRANDS_CONF_MAP = { "Whirlpool": Brand.Whirlpool, "Maytag": Brand.Maytag, "KitchenAid": Brand.KitchenAid, diff --git a/homeassistant/components/whirlpool/entity.py b/homeassistant/components/whirlpool/entity.py new file mode 100644 index 00000000000..a53fe0af263 --- /dev/null +++ b/homeassistant/components/whirlpool/entity.py @@ -0,0 +1,40 @@ +"""Base entity for the Whirlpool integration.""" + +from whirlpool.appliance import Appliance + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class WhirlpoolEntity(Entity): + """Base class for Whirlpool entities.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, appliance: Appliance, unique_id_suffix: str = "") -> None: + """Initialize the entity.""" + self._appliance = appliance + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, appliance.said)}, + name=appliance.name.capitalize() if appliance.name else appliance.said, + manufacturer="Whirlpool", + model_id=appliance.appliance_info.model_number, + ) + self._attr_unique_id = f"{appliance.said}{unique_id_suffix}" + + async def async_added_to_hass(self) -> None: + """Register attribute updates callback.""" + self._appliance.register_attr_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Unregister attribute updates callback.""" + self._appliance.unregister_attr_callback(self.async_write_ha_state) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._appliance.get_online() diff --git a/homeassistant/components/whirlpool/icons.json b/homeassistant/components/whirlpool/icons.json new file mode 100644 index 00000000000..574b491090e --- /dev/null +++ b/homeassistant/components/whirlpool/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "washer_state": { + "default": "mdi:washing-machine" + }, + "dryer_state": { + "default": "mdi:tumble-dryer" + } + } + } +} diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index ace2e31791d..919fa54c834 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -7,5 +7,6 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["whirlpool"], - "requirements": ["whirlpool-sixth-sense==0.19.1"] + "quality_scale": "bronze", + "requirements": ["whirlpool-sixth-sense==0.20.0"] } diff --git a/homeassistant/components/whirlpool/quality_scale.yaml b/homeassistant/components/whirlpool/quality_scale.yaml new file mode 100644 index 00000000000..1323a064d5c --- /dev/null +++ b/homeassistant/components/whirlpool/quality_scale.yaml @@ -0,0 +1,89 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: done + 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: + status: todo + comment: | + - The calls to the api can be changed to return bool, and services can then raise HomeAssistantError + - Current services raise ValueError and should raise ServiceValidationError instead. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration parameters + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: done + test-coverage: + status: todo + comment: | + - Test helper init_integration() does not set a unique_id + - Merge test_setup_http_exception and test_setup_auth_account_locked + - The climate platform is at 94% + + # Gold + devices: done + diagnostics: done + 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: 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: todo + entity-category: done + entity-device-class: + status: todo + comment: The "unknown" state should not be part of the enum for the dispense level sensor. + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: + status: todo + comment: | + Time remaining sensor still has hardcoded icon. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No known use cases for repair issues or flows, yet + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index d0d13a128e2..6b052834656 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -1,12 +1,11 @@ """The Washer/Dryer Sensor for Whirlpool Appliances.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -import logging +from typing import override +from whirlpool.appliance import Appliance from whirlpool.washerdryer import MachineState, WasherDryer from homeassistant.components.sensor import ( @@ -15,25 +14,26 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from . import WhirlpoolConfigEntry -from .const import DOMAIN +from .entity import WhirlpoolEntity -TANK_FILL = { - "0": "unknown", - "1": "empty", - "2": "25", - "3": "50", - "4": "100", - "5": "active", +SCAN_INTERVAL = timedelta(minutes=5) + +WASHER_TANK_FILL = { + 0: None, + 1: "empty", + 2: "25", + 3: "50", + 4: "100", + 5: "active", } -MACHINE_STATE = { +WASHER_DRYER_MACHINE_STATE = { MachineState.Standby: "standby", MachineState.Setting: "setting", MachineState.DelayCountdownMode: "delay_countdown", @@ -55,75 +55,92 @@ MACHINE_STATE = { MachineState.SystemInit: "system_initialize", } -CYCLE_FUNC = [ - (WasherDryer.get_cycle_status_filling, "cycle_filling"), - (WasherDryer.get_cycle_status_rinsing, "cycle_rinsing"), - (WasherDryer.get_cycle_status_sensing, "cycle_sensing"), - (WasherDryer.get_cycle_status_soaking, "cycle_soaking"), - (WasherDryer.get_cycle_status_spinning, "cycle_spinning"), - (WasherDryer.get_cycle_status_washing, "cycle_washing"), -] - -DOOR_OPEN = "door_open" -ICON_D = "mdi:tumble-dryer" -ICON_W = "mdi:washing-machine" - -_LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(minutes=5) +STATE_CYCLE_FILLING = "cycle_filling" +STATE_CYCLE_RINSING = "cycle_rinsing" +STATE_CYCLE_SENSING = "cycle_sensing" +STATE_CYCLE_SOAKING = "cycle_soaking" +STATE_CYCLE_SPINNING = "cycle_spinning" +STATE_CYCLE_WASHING = "cycle_washing" +STATE_DOOR_OPEN = "door_open" -def washer_state(washer: WasherDryer) -> str | None: - """Determine correct states for a washer.""" +def washer_dryer_state(washer_dryer: WasherDryer) -> str | None: + """Determine correct states for a washer/dryer.""" - if washer.get_attribute("Cavity_OpStatusDoorOpen") == "1": - return DOOR_OPEN + if washer_dryer.get_door_open(): + return STATE_DOOR_OPEN - machine_state = washer.get_machine_state() + machine_state = washer_dryer.get_machine_state() if machine_state == MachineState.RunningMainCycle: - for func, cycle_name in CYCLE_FUNC: - if func(washer): - return cycle_name + if washer_dryer.get_cycle_status_filling(): + return STATE_CYCLE_FILLING + if washer_dryer.get_cycle_status_rinsing(): + return STATE_CYCLE_RINSING + if washer_dryer.get_cycle_status_sensing(): + return STATE_CYCLE_SENSING + if washer_dryer.get_cycle_status_soaking(): + return STATE_CYCLE_SOAKING + if washer_dryer.get_cycle_status_spinning(): + return STATE_CYCLE_SPINNING + if washer_dryer.get_cycle_status_washing(): + return STATE_CYCLE_WASHING - return MACHINE_STATE.get(machine_state) + return WASHER_DRYER_MACHINE_STATE.get(machine_state) @dataclass(frozen=True, kw_only=True) class WhirlpoolSensorEntityDescription(SensorEntityDescription): - """Describes Whirlpool Washer sensor entity.""" + """Describes a Whirlpool sensor entity.""" - value_fn: Callable + value_fn: Callable[[Appliance], str | None] -SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( +WASHER_DRYER_STATE_OPTIONS = [ + *WASHER_DRYER_MACHINE_STATE.values(), + STATE_CYCLE_FILLING, + STATE_CYCLE_RINSING, + STATE_CYCLE_SENSING, + STATE_CYCLE_SOAKING, + STATE_CYCLE_SPINNING, + STATE_CYCLE_WASHING, + STATE_DOOR_OPEN, +] + +WASHER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( WhirlpoolSensorEntityDescription( key="state", - translation_key="whirlpool_machine", + translation_key="washer_state", device_class=SensorDeviceClass.ENUM, - options=( - list(MACHINE_STATE.values()) - + [value for _, value in CYCLE_FUNC] - + [DOOR_OPEN] - ), - value_fn=washer_state, + options=WASHER_DRYER_STATE_OPTIONS, + value_fn=washer_dryer_state, ), WhirlpoolSensorEntityDescription( key="DispenseLevel", translation_key="whirlpool_tank", entity_registry_enabled_default=False, device_class=SensorDeviceClass.ENUM, - options=list(TANK_FILL.values()), - value_fn=lambda WasherDryer: TANK_FILL.get( - WasherDryer.get_attribute("WashCavity_OpStatusBulkDispense1Level") - ), + options=[value for value in WASHER_TANK_FILL.values() if value], + value_fn=lambda washer: WASHER_TANK_FILL.get(washer.get_dispense_1_level()), ), ) -SENSOR_TIMER: tuple[SensorEntityDescription] = ( +DRYER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( + WhirlpoolSensorEntityDescription( + key="state", + translation_key="dryer_state", + device_class=SensorDeviceClass.ENUM, + options=WASHER_DRYER_STATE_OPTIONS, + value_fn=washer_dryer_state, + ), +) + +WASHER_DRYER_TIME_SENSORS: tuple[SensorEntityDescription] = ( SensorEntityDescription( key="timeremaining", translation_key="end_time", device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:progress-clock", ), ) @@ -137,110 +154,69 @@ async def async_setup_entry( entities: list = [] appliances_manager = config_entry.runtime_data for washer_dryer in appliances_manager.washer_dryers: + sensor_descriptions = ( + DRYER_SENSORS + if "dryer" in washer_dryer.appliance_info.data_model.lower() + else WASHER_SENSORS + ) + entities.extend( - [WasherDryerClass(washer_dryer, description) for description in SENSORS] + WhirlpoolSensor(washer_dryer, description) + for description in sensor_descriptions ) entities.extend( - [ - WasherDryerTimeClass(washer_dryer, description) - for description in SENSOR_TIMER - ] + WasherDryerTimeSensor(washer_dryer, description) + for description in WASHER_DRYER_TIME_SENSORS ) async_add_entities(entities) -class WasherDryerClass(SensorEntity): - """A class for the whirlpool/maytag washer account.""" - - _attr_should_poll = False - _attr_has_entity_name = True +class WhirlpoolSensor(WhirlpoolEntity, SensorEntity): + """A class for the Whirlpool sensors.""" def __init__( - self, washer_dryer: WasherDryer, description: WhirlpoolSensorEntityDescription + self, appliance: Appliance, description: WhirlpoolSensorEntityDescription ) -> None: """Initialize the washer sensor.""" - self._wd: WasherDryer = washer_dryer - - if washer_dryer.name == "dryer": - self._attr_icon = ICON_D - else: - self._attr_icon = ICON_W - + super().__init__(appliance, unique_id_suffix=f"-{description.key}") self.entity_description: WhirlpoolSensorEntityDescription = description - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, washer_dryer.said)}, - name=washer_dryer.name.capitalize(), - manufacturer="Whirlpool", - ) - self._attr_unique_id = f"{washer_dryer.said}-{description.key}" - - async def async_added_to_hass(self) -> None: - """Register updates callback.""" - self._wd.register_attr_callback(self.async_write_ha_state) - - async def async_will_remove_from_hass(self) -> None: - """Unregister updates callback.""" - self._wd.unregister_attr_callback(self.async_write_ha_state) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._wd.get_online() @property def native_value(self) -> StateType | str: """Return native value of sensor.""" - return self.entity_description.value_fn(self._wd) + return self.entity_description.value_fn(self._appliance) -class WasherDryerTimeClass(RestoreSensor): - """A timestamp class for the whirlpool/maytag washer account.""" +class WasherDryerTimeSensor(WhirlpoolEntity, RestoreSensor): + """A timestamp class for the Whirlpool washer/dryer.""" _attr_should_poll = True - _attr_has_entity_name = True def __init__( self, washer_dryer: WasherDryer, description: SensorEntityDescription ) -> None: """Initialize the washer sensor.""" - self._wd: WasherDryer = washer_dryer + super().__init__(washer_dryer, unique_id_suffix=f"-{description.key}") + self.entity_description = description - if washer_dryer.name == "dryer": - self._attr_icon = ICON_D - else: - self._attr_icon = ICON_W - - self.entity_description: SensorEntityDescription = description + self._wd = washer_dryer self._running: bool | None = None - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, washer_dryer.said)}, - name=washer_dryer.name.capitalize(), - manufacturer="Whirlpool", - ) - self._attr_unique_id = f"{washer_dryer.said}-{description.key}" + self._value: datetime | None = None async def async_added_to_hass(self) -> None: - """Connect washer/dryer to the cloud.""" + """Register attribute updates callback.""" if restored_data := await self.async_get_last_sensor_data(): - self._attr_native_value = restored_data.native_value + if isinstance(restored_data.native_value, datetime): + self._value = restored_data.native_value await super().async_added_to_hass() - self._wd.register_attr_callback(self.update_from_latest_data) - - async def async_will_remove_from_hass(self) -> None: - """Close Whrilpool Appliance sockets before removing.""" - self._wd.unregister_attr_callback(self.update_from_latest_data) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._wd.get_online() async def async_update(self) -> None: """Update status of Whirlpool.""" await self._wd.fetch_data() - @callback - def update_from_latest_data(self) -> None: + @override + @property + def native_value(self) -> datetime | None: """Calculate the time stamp for completion.""" machine_state = self._wd.get_machine_state() now = utcnow() @@ -250,19 +226,14 @@ class WasherDryerTimeClass(RestoreSensor): and self._running ): self._running = False - self._attr_native_value = now - self._async_write_ha_state() + self._value = now if machine_state is MachineState.RunningMainCycle: self._running = True - - new_timestamp = now + timedelta( - seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining")) - ) - - 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) + new_timestamp = now + timedelta(seconds=self._wd.get_time_remaining()) + if self._value is None or ( + isinstance(self._value, datetime) + and abs(new_timestamp - self._value) > timedelta(seconds=60) ): - self._attr_native_value = new_timestamp - self._async_write_ha_state() + self._value = new_timestamp + return self._value diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 95df3fb9098..2a22a2e8e4e 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -13,19 +13,23 @@ "brand": "Brand" }, "data_description": { - "brand": "Please choose the brand of the mobile app you use, or the brand of the appliances in your account" + "username": "The username or email address you use to log in to the Whirlpool/Maytag app", + "password": "The password you use to log in to the Whirlpool/Maytag app", + "region": "The region where your appliances where purchased", + "brand": "The brand of the mobile app you use, or the brand of the appliances in your account" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "For 'brand', please choose the brand of the mobile app you use, or the brand of the appliances in your account", "data": { "password": "[%key:common::config_flow::data::password%]", - "region": "Region", - "brand": "Brand" + "region": "[%key:component::whirlpool::config::step::user::data::region%]", + "brand": "[%key:component::whirlpool::config::step::user::data::brand%]" }, "data_description": { - "brand": "Please choose the brand of the mobile app you use, or the brand of the appliances in your account" + "password": "[%key:component::whirlpool::config::step::user::data_description::password%]", + "brand": "[%key:component::whirlpool::config::step::user::data_description::brand%]", + "region": "[%key:component::whirlpool::config::step::user::data_description::region%]" } } }, @@ -43,35 +47,66 @@ }, "entity": { "sensor": { - "whirlpool_machine": { + "washer_state": { "name": "State", "state": { "standby": "[%key:common::state::standby%]", "setting": "Setting", - "delay_countdown": "Delay Countdown", - "delay_paused": "Delay Paused", - "smart_delay": "Smart Delay", - "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::whirlpool_machine::state::smart_delay%]", + "delay_countdown": "Delay countdown", + "delay_paused": "Delay paused", + "smart_delay": "Smart delay", + "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]", "pause": "[%key:common::state::paused%]", - "running_maincycle": "Running Maincycle", - "running_postcycle": "Running Postcycle", + "running_maincycle": "Running maincycle", + "running_postcycle": "Running postcycle", "exception": "Exception", "complete": "Complete", - "power_failure": "Power Failure", - "service_diagnostic_mode": "Service Diagnostic Mode", - "factory_diagnostic_mode": "Factory Diagnostic Mode", - "life_test": "Life Test", - "customer_focus_mode": "Customer Focus Mode", - "demo_mode": "Demo Mode", - "hard_stop_or_error": "Hard Stop or Error", - "system_initialize": "System Initialize", - "cycle_filling": "Cycle Filling", - "cycle_rinsing": "Cycle Rinsing", - "cycle_sensing": "Cycle Sensing", - "cycle_soaking": "Cycle Soaking", - "cycle_spinning": "Cycle Spinning", - "cycle_washing": "Cycle Washing", - "door_open": "Door Open" + "power_failure": "Power failure", + "service_diagnostic_mode": "Service diagnostic mode", + "factory_diagnostic_mode": "Factory diagnostic mode", + "life_test": "Life test", + "customer_focus_mode": "Customer focus mode", + "demo_mode": "Demo mode", + "hard_stop_or_error": "Hard stop or error", + "system_initialize": "System initialize", + "cycle_filling": "Cycle filling", + "cycle_rinsing": "Cycle rinsing", + "cycle_sensing": "Cycle sensing", + "cycle_soaking": "Cycle soaking", + "cycle_spinning": "Cycle spinning", + "cycle_washing": "Cycle washing", + "door_open": "Door open" + } + }, + "dryer_state": { + "name": "[%key:component::whirlpool::entity::sensor::washer_state::name%]", + "state": { + "standby": "[%key:common::state::standby%]", + "setting": "[%key:component::whirlpool::entity::sensor::washer_state::state::setting%]", + "delay_countdown": "[%key:component::whirlpool::entity::sensor::washer_state::state::delay_countdown%]", + "delay_paused": "[%key:component::whirlpool::entity::sensor::washer_state::state::delay_paused%]", + "smart_delay": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]", + "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]", + "pause": "[%key:common::state::paused%]", + "running_maincycle": "[%key:component::whirlpool::entity::sensor::washer_state::state::running_maincycle%]", + "running_postcycle": "[%key:component::whirlpool::entity::sensor::washer_state::state::running_postcycle%]", + "exception": "[%key:component::whirlpool::entity::sensor::washer_state::state::exception%]", + "complete": "[%key:component::whirlpool::entity::sensor::washer_state::state::complete%]", + "power_failure": "[%key:component::whirlpool::entity::sensor::washer_state::state::power_failure%]", + "service_diagnostic_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::service_diagnostic_mode%]", + "factory_diagnostic_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::factory_diagnostic_mode%]", + "life_test": "[%key:component::whirlpool::entity::sensor::washer_state::state::life_test%]", + "customer_focus_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::customer_focus_mode%]", + "demo_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::demo_mode%]", + "hard_stop_or_error": "[%key:component::whirlpool::entity::sensor::washer_state::state::hard_stop_or_error%]", + "system_initialize": "[%key:component::whirlpool::entity::sensor::washer_state::state::system_initialize%]", + "cycle_filling": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_filling%]", + "cycle_rinsing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_rinsing%]", + "cycle_sensing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_sensing%]", + "cycle_soaking": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_soaking%]", + "cycle_spinning": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_spinning%]", + "cycle_washing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_washing%]", + "door_open": "[%key:component::whirlpool::entity::sensor::washer_state::state::door_open%]" } }, "whirlpool_tank": { @@ -93,6 +128,9 @@ "exceptions": { "account_locked": { "message": "[%key:component::whirlpool::common::account_locked_error%]" + }, + "appliances_fetch_failed": { + "message": "Failed to fetch appliances" } } } diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 775ef5cdaab..746fa244c8e 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -313,9 +313,9 @@ "battery": { "name": "[%key:component::sensor::entity_component::battery::name%]", "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } } diff --git a/homeassistant/components/wiz/manifest.json b/homeassistant/components/wiz/manifest.json index 7b1ecdcdb6b..947e7f0b638 100644 --- a/homeassistant/components/wiz/manifest.json +++ b/homeassistant/components/wiz/manifest.json @@ -26,5 +26,5 @@ ], "documentation": "https://www.home-assistant.io/integrations/wiz", "iot_class": "local_push", - "requirements": ["pywizlight==0.5.14"] + "requirements": ["pywizlight==0.6.2"] } diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index 715add3023f..d46ffa6dab6 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -32,25 +32,29 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [] for dest in hub.dests.values(): if dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive): - entities.append(WebControlProAwning(config_entry.entry_id, dest)) # noqa: PERF401 + entities.append(WebControlProAwning(config_entry.entry_id, dest)) + elif dest.action( + WMS_WebControl_pro_API_actionDescription.RollerShutterBlindDrive + ): + entities.append(WebControlProRollerShutter(config_entry.entry_id, dest)) async_add_entities(entities) -class WebControlProAwning(WebControlProGenericEntity, CoverEntity): - """Representation of a WMS based awning.""" +class WebControlProCover(WebControlProGenericEntity, CoverEntity): + """Base representation of a WMS based cover.""" - _attr_device_class = CoverDeviceClass.AWNING + _drive_action_desc: WMS_WebControl_pro_API_actionDescription @property def current_cover_position(self) -> int | None: """Return current position of cover.""" - action = self._dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive) + action = self._dest.action(self._drive_action_desc) return 100 - action["percentage"] async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - action = self._dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive) + action = self._dest.action(self._drive_action_desc) await action(percentage=100 - kwargs[ATTR_POSITION]) @property @@ -60,12 +64,12 @@ class WebControlProAwning(WebControlProGenericEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - action = self._dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive) + action = self._dest.action(self._drive_action_desc) await action(percentage=0) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - action = self._dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive) + action = self._dest.action(self._drive_action_desc) await action(percentage=100) async def async_stop_cover(self, **kwargs: Any) -> None: @@ -75,3 +79,19 @@ class WebControlProAwning(WebControlProGenericEntity, CoverEntity): WMS_WebControl_pro_API_actionType.Stop, ) await action() + + +class WebControlProAwning(WebControlProCover): + """Representation of a WMS based awning.""" + + _attr_device_class = CoverDeviceClass.AWNING + _drive_action_desc = WMS_WebControl_pro_API_actionDescription.AwningDrive + + +class WebControlProRollerShutter(WebControlProCover): + """Representation of a WMS based roller shutter or blind.""" + + _attr_device_class = CoverDeviceClass.SHUTTER + _drive_action_desc = ( + WMS_WebControl_pro_API_actionDescription.RollerShutterBlindDrive + ) diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json index dd65be3e7e7..d4eda3a90a6 100644 --- a/homeassistant/components/wmspro/manifest.json +++ b/homeassistant/components/wmspro/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/wmspro", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["pywmspro==0.2.1"] + "requirements": ["pywmspro==0.2.2"] } diff --git a/homeassistant/components/wolflink/strings.json b/homeassistant/components/wolflink/strings.json index b1c332984a1..ba746a579cd 100644 --- a/homeassistant/components/wolflink/strings.json +++ b/homeassistant/components/wolflink/strings.json @@ -28,14 +28,15 @@ "sensor": { "state": { "state": { - "ein": "[%key:common::state::enabled%]", - "deaktiviert": "Inactive", - "aus": "[%key:common::state::disabled%]", + "ein": "[%key:common::state::on%]", + "aus": "[%key:common::state::off%]", + "deaktiviert": "[%key:common::state::disabled%]", "standby": "[%key:common::state::standby%]", - "auto": "Auto", + "storung": "[%key:common::state::fault%]", + "auto": "[%key:common::state::auto%]", "permanent": "Permanent", "initialisierung": "Initialization", - "antilegionellenfunktion": "Anti-legionella Function", + "antilegionellenfunktion": "Anti-legionella function", "fernschalter_ein": "Remote control enabled", "1_x_warmwasser": "1 x DHW", "bereit_keine_ladung": "Ready, not loading", @@ -53,7 +54,6 @@ "taktsperre": "Anti-cycle", "betrieb_ohne_brenner": "Working without burner", "abgasklappe": "Flue gas damper", - "storung": "Fault", "gradienten_uberwachung": "Gradient monitoring", "gasdruck": "Gas pressure", "spreizung_hoch": "dT too wide", diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 895c7cd50e2..b0b1e9fcc02 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -26,6 +26,7 @@ from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, NumberSelectorMode, + SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -79,10 +80,19 @@ def add_province_and_language_to_schema( } if provinces := all_countries.get(country): + if _country.subdivisions_aliases and ( + subdiv_aliases := _country.get_subdivision_aliases() + ): + province_options: list[Any] = [ + SelectOptionDict(value=k, label=", ".join(v)) + for k, v in subdiv_aliases.items() + ] + else: + province_options = provinces province_schema = { vol.Optional(CONF_PROVINCE): SelectSelector( SelectSelectorConfig( - options=provinces, + options=province_options, mode=SelectSelectorMode.DROPDOWN, translation_key=CONF_PROVINCE, ) diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index 5440b2bebeb..88939f0ba77 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -178,7 +178,11 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): self._pipeline_ended_event.set() self.device.set_is_active(False) elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_START: - self.hass.add_job(self._client.write_event(Detect().event())) + self.config_entry.async_create_background_task( + self.hass, + self._client.write_event(Detect().event()), + f"{self.entity_id} {event.type}", + ) elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_END: # Wake word detection # Inform client of wake word detection @@ -187,46 +191,59 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): name=wake_word_output["wake_word_id"], timestamp=wake_word_output.get("timestamp"), ) - self.hass.add_job(self._client.write_event(detection.event())) + self.config_entry.async_create_background_task( + self.hass, + self._client.write_event(detection.event()), + f"{self.entity_id} {event.type}", + ) elif event.type == assist_pipeline.PipelineEventType.STT_START: # Speech-to-text self.device.set_is_active(True) if event.data: - self.hass.add_job( + self.config_entry.async_create_background_task( + self.hass, self._client.write_event( Transcribe(language=event.data["metadata"]["language"]).event() - ) + ), + f"{self.entity_id} {event.type}", ) elif event.type == assist_pipeline.PipelineEventType.STT_VAD_START: # User started speaking if event.data: - self.hass.add_job( + self.config_entry.async_create_background_task( + self.hass, self._client.write_event( VoiceStarted(timestamp=event.data["timestamp"]).event() - ) + ), + f"{self.entity_id} {event.type}", ) elif event.type == assist_pipeline.PipelineEventType.STT_VAD_END: # User stopped speaking if event.data: - self.hass.add_job( + self.config_entry.async_create_background_task( + self.hass, self._client.write_event( VoiceStopped(timestamp=event.data["timestamp"]).event() - ) + ), + f"{self.entity_id} {event.type}", ) elif event.type == assist_pipeline.PipelineEventType.STT_END: # Speech-to-text transcript if event.data: # Inform client of transript stt_text = event.data["stt_output"]["text"] - self.hass.add_job( - self._client.write_event(Transcript(text=stt_text).event()) + self.config_entry.async_create_background_task( + self.hass, + self._client.write_event(Transcript(text=stt_text).event()), + f"{self.entity_id} {event.type}", ) elif event.type == assist_pipeline.PipelineEventType.TTS_START: # Text-to-speech text if event.data: # Inform client of text - self.hass.add_job( + self.config_entry.async_create_background_task( + self.hass, self._client.write_event( Synthesize( text=event.data["tts_input"], @@ -235,22 +252,32 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): language=event.data.get("language"), ), ).event() - ) + ), + f"{self.entity_id} {event.type}", ) elif event.type == assist_pipeline.PipelineEventType.TTS_END: # TTS stream - if event.data and (tts_output := event.data["tts_output"]): - media_id = tts_output["media_id"] - self.hass.add_job(self._stream_tts(media_id)) + if ( + event.data + and (tts_output := event.data["tts_output"]) + and (stream := tts.async_get_stream(self.hass, tts_output["token"])) + ): + self.config_entry.async_create_background_task( + self.hass, + self._stream_tts(stream), + f"{self.entity_id} {event.type}", + ) elif event.type == assist_pipeline.PipelineEventType.ERROR: # Pipeline error if event.data: - self.hass.add_job( + self.config_entry.async_create_background_task( + self.hass, self._client.write_event( Error( text=event.data["message"], code=event.data["code"] ).event() - ) + ), + f"{self.entity_id} {event.type}", ) async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> None: @@ -662,13 +689,16 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): await self._client.disconnect() self._client = None - async def _stream_tts(self, media_id: str) -> None: + async def _stream_tts(self, tts_result: tts.ResultStream) -> None: """Stream TTS WAV audio to satellite in chunks.""" assert self._client is not None - extension, data = await tts.async_get_media_source_audio(self.hass, media_id) - if extension != "wav": - raise ValueError(f"Cannot stream audio format to satellite: {extension}") + if tts_result.extension != "wav": + raise ValueError( + f"Cannot stream audio format to satellite: {tts_result.extension}" + ) + + data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: sample_rate = wav_file.getframerate() diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 4a1a4c3a246..2578b0e5278 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -40,10 +40,10 @@ "noise_suppression_level": { "name": "Noise suppression level", "state": { - "off": "Off", - "low": "Low", - "medium": "Medium", - "high": "High", + "off": "[%key:common::state::off%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "max": "Max" } }, diff --git a/homeassistant/components/xiaomi_ble/const.py b/homeassistant/components/xiaomi_ble/const.py index 8ea99cf1f84..aab443c67fa 100644 --- a/homeassistant/components/xiaomi_ble/const.py +++ b/homeassistant/components/xiaomi_ble/const.py @@ -37,6 +37,7 @@ LOCK_FINGERPRINT = "lock_fingerprint" MOTION_DEVICE: Final = "motion_device" DOUBLE_BUTTON: Final = "double_button" TRIPPLE_BUTTON: Final = "tripple_button" +QUADRUPLE_BUTTON: Final = "quadruple_button" REMOTE: Final = "remote" REMOTE_FAN: Final = "remote_fan" REMOTE_VENFAN: Final = "remote_ventilator_fan" @@ -48,6 +49,7 @@ BUTTON_PRESS_LONG: Final = "button_press_long" BUTTON_PRESS_DOUBLE_LONG: Final = "button_press_double_long" DOUBLE_BUTTON_PRESS_DOUBLE_LONG: Final = "double_button_press_double_long" TRIPPLE_BUTTON_PRESS_DOUBLE_LONG: Final = "tripple_button_press_double_long" +QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG: Final = "quadruple_button_press_double_long" class XiaomiBleEvent(TypedDict): diff --git a/homeassistant/components/xiaomi_ble/device_trigger.py b/homeassistant/components/xiaomi_ble/device_trigger.py index 119424788db..3c5488a1e74 100644 --- a/homeassistant/components/xiaomi_ble/device_trigger.py +++ b/homeassistant/components/xiaomi_ble/device_trigger.py @@ -47,6 +47,8 @@ from .const import ( LOCK_FINGERPRINT, MOTION, MOTION_DEVICE, + QUADRUPLE_BUTTON, + QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG, REMOTE, REMOTE_BATHROOM, REMOTE_FAN, @@ -123,6 +125,12 @@ EVENT_TYPES = { DIMMER: ["dimmer"], DOUBLE_BUTTON: ["button_left", "button_right"], TRIPPLE_BUTTON: ["button_left", "button_middle", "button_right"], + QUADRUPLE_BUTTON: [ + "button_left", + "button_mid_left", + "button_mid_right", + "button_right", + ], ERROR: ["error"], FINGERPRINT: ["fingerprint"], LOCK: ["lock"], @@ -205,6 +213,11 @@ TRIGGER_MODEL_DATA = { event_types=EVENT_TYPES[TRIPPLE_BUTTON], triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], ), + QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[QUADRUPLE_BUTTON], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], + ), ERROR: TriggerModelData( event_class=EVENT_CLASS_ERROR, event_types=EVENT_TYPES[ERROR], @@ -261,6 +274,8 @@ MODEL_DATA = { "XMWXKG01YL": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG], "K9B-2BTN": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG], "K9B-3BTN": TRIGGER_MODEL_DATA[TRIPPLE_BUTTON_PRESS_DOUBLE_LONG], + "KS1": TRIGGER_MODEL_DATA[QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG], + "KS1BP": TRIGGER_MODEL_DATA[QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG], "YLYK01YL": TRIGGER_MODEL_DATA[REMOTE], "YLYK01YL-FANRC": TRIGGER_MODEL_DATA[REMOTE_FAN], "YLYK01YL-VENFAN": TRIGGER_MODEL_DATA[REMOTE_VENFAN], diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 26dd82c73bc..3f13c7921a8 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.33.0"] + "requirements": ["xiaomi-ble==0.38.0"] } diff --git a/homeassistant/components/xiaomi_ble/sensor.py b/homeassistant/components/xiaomi_ble/sensor.py index 01f15ff09b8..0fcae1925bb 100644 --- a/homeassistant/components/xiaomi_ble/sensor.py +++ b/homeassistant/components/xiaomi_ble/sensor.py @@ -9,9 +9,11 @@ from xiaomi_ble.parser import ExtendedSensorDeviceClass from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataUpdate, + PassiveBluetoothEntityKey, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( + EntityDescription, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -78,6 +80,7 @@ SENSOR_DESCRIPTIONS = { icon="mdi:omega", native_unit_of_measurement=Units.OHM, state_class=SensorStateClass.MEASUREMENT, + translation_key="impedance", ), # Mass sensor (kg) (DeviceClass.MASS, Units.MASS_KILOGRAMS): SensorEntityDescription( @@ -93,6 +96,7 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfMass.KILOGRAMS, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + translation_key="weight_non_stabilized", ), (DeviceClass.MOISTURE, Units.PERCENTAGE): SensorEntityDescription( key=f"{DeviceClass.MOISTURE}_{Units.PERCENTAGE}", @@ -173,6 +177,25 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, ), + # Low frequency impedance sensor (ohm) + (ExtendedSensorDeviceClass.IMPEDANCE_LOW, Units.OHM): SensorEntityDescription( + key=str(ExtendedSensorDeviceClass.IMPEDANCE_LOW), + native_unit_of_measurement=Units.OHM, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:omega", + ), + # Heart rate sensor (bpm) + (ExtendedSensorDeviceClass.HEART_RATE, "bpm"): SensorEntityDescription( + key=str(ExtendedSensorDeviceClass.HEART_RATE), + native_unit_of_measurement="bpm", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:heart-pulse", + ), + # User profile ID sensor + (ExtendedSensorDeviceClass.PROFILE_ID, None): SensorEntityDescription( + key=str(ExtendedSensorDeviceClass.PROFILE_ID), + icon="mdi:identifier", + ), } @@ -180,18 +203,20 @@ def sensor_update_to_bluetooth_data_update( sensor_update: SensorUpdate, ) -> PassiveBluetoothDataUpdate[float | None]: """Convert a sensor update to a bluetooth data update.""" + entity_descriptions: dict[PassiveBluetoothEntityKey, EntityDescription] = { + device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ + (description.device_class, description.native_unit_of_measurement) + ] + for device_key, description in sensor_update.entity_descriptions.items() + if description.device_class + } + return PassiveBluetoothDataUpdate( devices={ device_id: sensor_device_info_to_hass_device_info(device_info) for device_id, device_info in sensor_update.devices.items() }, - entity_descriptions={ - device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ - (description.device_class, description.native_unit_of_measurement) - ] - for device_key, description in sensor_update.entity_descriptions.items() - if description.device_class - }, + entity_descriptions=entity_descriptions, entity_data={ device_key_to_bluetooth_entity_key(device_key): cast( float | None, sensor_values.native_value @@ -201,6 +226,17 @@ def sensor_update_to_bluetooth_data_update( entity_names={ device_key_to_bluetooth_entity_key(device_key): sensor_values.name for device_key, sensor_values in sensor_update.entity_values.items() + # Add names where the entity description has neither a translation_key nor + # a device_class + if ( + description := entity_descriptions.get( + device_key_to_bluetooth_entity_key(device_key) + ) + ) + is None + or ( + description.translation_key is None and description.device_class is None + ) }, ) diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index 4ea4a47c61e..06b49b8e86f 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -86,6 +86,8 @@ "trigger_type": { "button": "Button \"{subtype}\"", "button_left": "Button Left \"{subtype}\"", + "button_mid_left": "Button Mid Left \"{subtype}\"", + "button_mid_right": "Button Mid Right \"{subtype}\"", "button_middle": "Button Middle \"{subtype}\"", "button_right": "Button Right \"{subtype}\"", "button_on": "Button On \"{subtype}\"", @@ -227,6 +229,14 @@ } } } + }, + "sensor": { + "impedance": { + "name": "Impedance" + }, + "weight_non_stabilized": { + "name": "Weight non stabilized" + } } } } diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index bd3b3499689..a5af3d8bd1f 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -14,7 +14,7 @@ "unknown_device": "The device model is not known, not able to set up the device using config flow.", "cloud_no_devices": "No devices found in this Xiaomi Miio cloud account.", "cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country", - "cloud_login_error": "Could not login to Xiaomi Miio Cloud, check the credentials." + "cloud_login_error": "Could not log in to Xiaomi Miio Cloud, check the credentials." }, "flow_title": "{name}", "step": { @@ -82,15 +82,15 @@ "airpurifier_mode": { "state": { "silent": "Silent", - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "favorite": "Favorite" } }, "ptc_level": { "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } }, @@ -100,7 +100,7 @@ "preset_mode": { "state": { "nature": "Nature", - "normal": "Normal" + "normal": "[%key:common::state::normal%]" } } } diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index 5c8e98b1e6e..4d9ea9ec2c9 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.7"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==2.6.0"] } diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index ebcf0b3af63..fd8d403da8d 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -71,8 +71,8 @@ "volume": { "name": "Volume", "state": { - "high": "High", - "low": "Low", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "off": "[%key:common::state::off%]" } } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index c44f0fdd1e9..2387f5dc15f 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.5.7"] + "requirements": ["yalexs-ble==2.6.0"] } diff --git a/homeassistant/components/yamaha_musiccast/strings.json b/homeassistant/components/yamaha_musiccast/strings.json index eaa5ac50c80..e38eb5955d9 100644 --- a/homeassistant/components/yamaha_musiccast/strings.json +++ b/homeassistant/components/yamaha_musiccast/strings.json @@ -29,29 +29,29 @@ "select": { "dimmer": { "state": { - "auto": "Auto" + "auto": "[%key:common::state::auto%]" } }, "zone_sleep": { "state": { "off": "[%key:common::state::off%]", - "30_min": "30 Minutes", - "60_min": "60 Minutes", - "90_min": "90 Minutes", - "120_min": "120 Minutes" + "30_min": "30 minutes", + "60_min": "60 minutes", + "90_min": "90 minutes", + "120_min": "120 minutes" } }, "zone_tone_control_mode": { "state": { - "manual": "Manual", - "auto": "Auto", + "manual": "[%key:common::state::manual%]", + "auto": "[%key:common::state::auto%]", "bypass": "Bypass" } }, "zone_surr_decoder_type": { "state": { "toggle": "[%key:common::action::toggle%]", - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "dolby_pl": "Dolby ProLogic", "dolby_pl2x_movie": "Dolby ProLogic 2x Movie", "dolby_pl2x_music": "Dolby ProLogic 2x Music", @@ -64,8 +64,8 @@ }, "zone_equalizer_mode": { "state": { - "manual": "Manual", - "auto": "Auto", + "manual": "[%key:common::state::manual%]", + "auto": "[%key:common::state::auto%]", "bypass": "[%key:component::yamaha_musiccast::entity::select::zone_tone_control_mode::state::bypass%]" } }, @@ -84,11 +84,11 @@ }, "zone_link_audio_delay": { "state": { - "audio_sync_on": "Audio Synchronization On", - "audio_sync_off": "Audio Synchronization Off", + "audio_sync_on": "Audio synchronization on", + "audio_sync_off": "Audio synchronization off", "balanced": "Balanced", - "lip_sync": "Lip Synchronization", - "audio_sync": "Audio Synchronization" + "lip_sync": "Lip synchronization", + "audio_sync": "Audio synchronization" } } } diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index d53c28cb64a..e01a853a360 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -73,7 +73,7 @@ "fields": { "rgb_color": { "name": "RGB color", - "description": "Color for the light in RGB-format." + "description": "Color for the light in RGB format." }, "brightness": { "name": "Brightness", @@ -173,11 +173,11 @@ "selector": { "mode": { "options": { - "color_flow": "Color Flow", + "normal": "[%key:common::state::normal%]", + "color_flow": "Color flow", "hsv": "HSV", "last": "Last", "moonlight": "Moonlight", - "normal": "Normal", "rgb": "RGB" } }, diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 8c297c68670..74e2259f050 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.4.9"] + "requirements": ["yolink-api==0.5.2"] } diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 8ec7612fd73..8867457342f 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -61,8 +61,8 @@ "power_failure_alarm": { "name": "Power failure alarm", "state": { - "normal": "Normal", "alert": "Alert", + "normal": "[%key:common::state::normal%]", "off": "[%key:common::state::off%]" } }, @@ -72,7 +72,11 @@ }, "power_failure_alarm_volume": { "name": "Power failure alarm volume", - "state": { "low": "Low", "medium": "Medium", "high": "High" } + "state": { + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" + } }, "power_failure_alarm_beep": { "name": "Power failure alarm beep", diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py index 48336422585..76d74965b34 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -7,7 +7,6 @@ import logging from typing import Any import voluptuous as vol -from youtubeaio.helper import first from youtubeaio.types import AuthScope, ForbiddenError from youtubeaio.youtube import YouTube @@ -96,8 +95,12 @@ class OAuth2FlowHandler( """Create an entry for the flow, or update existing entry.""" try: youtube = await self.get_resource(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) - own_channel = await first(youtube.get_user_channels()) - if own_channel is None or own_channel.snippet is None: + own_channels = [ + channel + async for channel in youtube.get_user_channels() + if channel.snippet is not None + ] + if not own_channels: return self.async_abort( reason="no_channel", description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL}, @@ -111,10 +114,10 @@ class OAuth2FlowHandler( except Exception as ex: # noqa: BLE001 LOGGER.error("Unknown error occurred: %s", ex.args) return self.async_abort(reason="unknown") - self._title = own_channel.snippet.title + self._title = own_channels[0].snippet.title self._data = data - await self.async_set_unique_id(own_channel.channel_id) + await self.async_set_unique_id(own_channels[0].channel_id) if self.source != SOURCE_REAUTH: self._abort_if_unique_id_configured() @@ -138,13 +141,39 @@ class OAuth2FlowHandler( options=user_input, ) youtube = await self.get_resource(self._data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + + # Get user's own channels + own_channels = [ + channel + async for channel in youtube.get_user_channels() + if channel.snippet is not None + ] + if not own_channels: + return self.async_abort( + reason="no_channel", + description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL}, + ) + + # Start with user's own channels selectable_channels = [ SelectOptionDict( - value=subscription.snippet.channel_id, - label=subscription.snippet.title, + value=channel.channel_id, + label=f"{channel.snippet.title} (Your Channel)", ) - async for subscription in youtube.get_user_subscriptions() + for channel in own_channels ] + + # Add subscribed channels + selectable_channels.extend( + [ + SelectOptionDict( + value=subscription.snippet.channel_id, + label=subscription.snippet.title, + ) + async for subscription in youtube.get_user_subscriptions() + ] + ) + if not selectable_channels: return self.async_abort(reason="no_subscriptions") return self.async_show_form( @@ -175,13 +204,39 @@ class YouTubeOptionsFlowHandler(OptionsFlow): await youtube.set_user_authentication( self.config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN], [AuthScope.READ_ONLY] ) + + # Get user's own channels + own_channels = [ + channel + async for channel in youtube.get_user_channels() + if channel.snippet is not None + ] + if not own_channels: + return self.async_abort( + reason="no_channel", + description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL}, + ) + + # Start with user's own channels selectable_channels = [ SelectOptionDict( - value=subscription.snippet.channel_id, - label=subscription.snippet.title, + value=channel.channel_id, + label=f"{channel.snippet.title} (Your Channel)", ) - async for subscription in youtube.get_user_subscriptions() + for channel in own_channels ] + + # Add subscribed channels + selectable_channels.extend( + [ + SelectOptionDict( + value=subscription.snippet.channel_id, + label=subscription.snippet.title, + ) + async for subscription in youtube.get_user_subscriptions() + ] + ) + return self.async_show_form( step_id="init", data_schema=self.add_suggested_values_to_schema( diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 86f8dbca792..311c42ee18e 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -2,26 +2,17 @@ from __future__ import annotations -import contextlib from contextlib import suppress -from fnmatch import translate -from functools import lru_cache, partial +from functools import partial from ipaddress import IPv4Address, IPv6Address import logging -import re import sys -from typing import TYPE_CHECKING, Any, Final, cast +from typing import Any, cast import voluptuous as vol -from zeroconf import ( - BadTypeInNameException, - InterfaceChoice, - IPVersion, - ServiceStateChange, -) -from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo +from zeroconf import InterfaceChoice, IPVersion +from zeroconf.asyncio import AsyncServiceInfo -from homeassistant import config_entries from homeassistant.components import network from homeassistant.const import ( EVENT_HOMEASSISTANT_CLOSE, @@ -29,55 +20,41 @@ from homeassistant.const import ( __version__, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, discovery_flow, instance_id +from homeassistant.helpers import config_validation as cv, 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, - ZeroconfMatcher, - async_get_homekit, - async_get_zeroconf, - bind_hass, -) +from homeassistant.loader import async_get_homekit, async_get_zeroconf, bind_hass from homeassistant.setup import async_when_setup_or_start +from . import websocket_api +from .const import DOMAIN, ZEROCONF_TYPE +from .discovery import ( # noqa: F401 + DATA_DISCOVERY, + ZeroconfDiscovery, + build_homekit_model_lookups, + info_from_service, +) from .models import HaAsyncZeroconf, HaZeroconf from .usage import install_multiple_zeroconf_catcher _LOGGER = logging.getLogger(__name__) -DOMAIN = "zeroconf" - -ZEROCONF_TYPE = "_home-assistant._tcp.local." -HOMEKIT_TYPES = [ - "_hap._tcp.local.", - # Thread based devices - "_hap._udp.local.", -] -_HOMEKIT_MODEL_SPLITS = (None, " ", "-") - CONF_DEFAULT_INTERFACE = "default_interface" CONF_IPV6 = "ipv6" DEFAULT_DEFAULT_INTERFACE = True DEFAULT_IPV6 = True -HOMEKIT_PAIRED_STATUS_FLAG = "sf" -HOMEKIT_MODEL_LOWER = "md" -HOMEKIT_MODEL_UPPER = "MD" - # Property key=value has a max length of 255 # so we use 230 to leave space for key= MAX_PROPERTY_VALUE_LEN = 230 @@ -85,10 +62,6 @@ MAX_PROPERTY_VALUE_LEN = 230 # Dns label max length MAX_NAME_LEN = 63 -ATTR_DOMAIN: Final = "domain" -ATTR_NAME: Final = "name" -ATTR_PROPERTIES: Final = "properties" - # Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] _DEPRECATED_ATTR_PROPERTIES_ID = DeprecatedConstant( _ATTR_PROPERTIES_ID, @@ -214,7 +187,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: zeroconf = cast(HaZeroconf, aio_zc.zeroconf) zeroconf_types = await async_get_zeroconf(hass) homekit_models = await async_get_homekit(hass) - homekit_model_lookup, homekit_model_matchers = _build_homekit_model_lookups( + homekit_model_lookup, homekit_model_matchers = build_homekit_model_lookups( homekit_models ) discovery = ZeroconfDiscovery( @@ -225,6 +198,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: homekit_model_matchers, ) await discovery.async_setup() + hass.data[DATA_DISCOVERY] = discovery + websocket_api.async_setup(hass) async def _async_zeroconf_hass_start(hass: HomeAssistant, comp: str) -> None: """Expose Home Assistant on zeroconf when it starts. @@ -243,25 +218,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def _build_homekit_model_lookups( - homekit_models: dict[str, HomeKitDiscoveredIntegration], -) -> tuple[ - dict[str, HomeKitDiscoveredIntegration], - dict[re.Pattern, HomeKitDiscoveredIntegration], -]: - """Build lookups for homekit models.""" - homekit_model_lookup: dict[str, HomeKitDiscoveredIntegration] = {} - homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration] = {} - - for model, discovery in homekit_models.items(): - if "*" in model or "?" in model or "[" in model: - homekit_model_matchers[_compile_fnmatch(model)] = discovery - else: - homekit_model_lookup[model] = discovery - - return homekit_model_lookup, homekit_model_matchers - - def _filter_disallowed_characters(name: str) -> str: """Filter disallowed characters from a string. @@ -315,299 +271,6 @@ async def _async_register_hass_zc_service( await aio_zc.async_register_service(info, allow_name_change=True) -def _match_against_props(matcher: dict[str, str], props: dict[str, str | None]) -> bool: - """Check a matcher to ensure all values in props.""" - for key, value in matcher.items(): - prop_val = props.get(key) - if prop_val is None or not _memorized_fnmatch(prop_val.lower(), value): - return False - return True - - -def is_homekit_paired(props: dict[str, Any]) -> bool: - """Check properties to see if a device is homekit paired.""" - if HOMEKIT_PAIRED_STATUS_FLAG not in props: - return False - with contextlib.suppress(ValueError): - # 0 means paired and not discoverable by iOS clients) - return int(props[HOMEKIT_PAIRED_STATUS_FLAG]) == 0 - # If we cannot tell, we assume its not paired - return False - - -class ZeroconfDiscovery: - """Discovery via zeroconf.""" - - def __init__( - self, - hass: HomeAssistant, - zeroconf: HaZeroconf, - zeroconf_types: dict[str, list[ZeroconfMatcher]], - homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], - homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], - ) -> None: - """Init discovery.""" - self.hass = hass - self.zeroconf = zeroconf - self.zeroconf_types = zeroconf_types - self.homekit_model_lookups = homekit_model_lookups - self.homekit_model_matchers = homekit_model_matchers - self.async_service_browser: AsyncServiceBrowser | None = None - - async def async_setup(self) -> None: - """Start discovery.""" - types = list(self.zeroconf_types) - # We want to make sure we know about other HomeAssistant - # instances as soon as possible to avoid name conflicts - # so we always browse for ZEROCONF_TYPE - types.extend( - hk_type - for hk_type in (ZEROCONF_TYPE, *HOMEKIT_TYPES) - if hk_type not in self.zeroconf_types - ) - _LOGGER.debug("Starting Zeroconf browser for: %s", types) - self.async_service_browser = AsyncServiceBrowser( - self.zeroconf, types, handlers=[self.async_service_update] - ) - - async_dispatcher_connect( - self.hass, - config_entries.signal_discovered_config_entry_removed(DOMAIN), - self._handle_config_entry_removed, - ) - - async def async_stop(self) -> None: - """Cancel the service browser and stop processing the queue.""" - if self.async_service_browser: - await self.async_service_browser.async_cancel() - - @callback - def _handle_config_entry_removed( - self, - entry: config_entries.ConfigEntry, - ) -> None: - """Handle config entry changes.""" - for discovery_key in entry.discovery_keys[DOMAIN]: - if discovery_key.version != 1: - continue - _type = discovery_key.key[0] - name = discovery_key.key[1] - _LOGGER.debug("Rediscover service %s.%s", _type, name) - self._async_service_update(self.zeroconf, _type, name) - - 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, - lambda service_info: bool(service_info.name == name), - ): - self.hass.config_entries.flow.async_abort(flow["flow_id"]) - - @callback - def async_service_update( - self, - zeroconf: HaZeroconf, - service_type: str, - name: str, - state_change: ServiceStateChange, - ) -> None: - """Service state changed.""" - _LOGGER.debug( - "service_update: type=%s name=%s state_change=%s", - service_type, - name, - state_change, - ) - - if state_change is ServiceStateChange.Removed: - self._async_dismiss_discoveries(name) - return - - self._async_service_update(zeroconf, service_type, name) - - @callback - def _async_service_update( - self, - zeroconf: HaZeroconf, - service_type: str, - name: str, - ) -> None: - """Service state added or changed.""" - try: - async_service_info = AsyncServiceInfo(service_type, name) - except BadTypeInNameException as ex: - # Some devices broadcast a name that is not a valid DNS name - # This is a bug in the device firmware and we should ignore it - _LOGGER.debug("Bad name in zeroconf record: %s: %s", name, ex) - return - - if async_service_info.load_from_cache(zeroconf): - self._async_process_service_update(async_service_info, service_type, name) - else: - self.hass.async_create_background_task( - self._async_lookup_and_process_service_update( - zeroconf, async_service_info, service_type, name - ), - name=f"zeroconf lookup {name}.{service_type}", - ) - - async def _async_lookup_and_process_service_update( - self, - zeroconf: HaZeroconf, - async_service_info: AsyncServiceInfo, - service_type: str, - name: str, - ) -> None: - """Update and process a zeroconf update.""" - await async_service_info.async_request(zeroconf, 3000) - self._async_process_service_update(async_service_info, service_type, name) - - @callback - def _async_process_service_update( - self, async_service_info: AsyncServiceInfo, service_type: str, name: str - ) -> None: - """Process a zeroconf update.""" - info = info_from_service(async_service_info) - if not info: - # Prevent the browser thread from collapsing - _LOGGER.debug("Failed to get addresses for device %s", name) - return - _LOGGER.debug("Discovered new device %s %s", name, info) - props: dict[str, str | None] = info.properties - discovery_key = DiscoveryKey( - domain=DOMAIN, - key=(info.type, info.name), - version=1, - ) - domain = None - - # If we can handle it as a HomeKit discovery, we do that here. - if service_type in HOMEKIT_TYPES and ( - homekit_discovery := async_get_homekit_discovery( - self.homekit_model_lookups, self.homekit_model_matchers, props - ) - ): - domain = homekit_discovery.domain - discovery_flow.async_create_flow( - self.hass, - homekit_discovery.domain, - {"source": config_entries.SOURCE_HOMEKIT}, - info, - discovery_key=discovery_key, - ) - # Continue on here as homekit_controller - # still needs to get updates on devices - # so it can see when the 'c#' field is updated. - # - # We only send updates to homekit_controller - # if the device is already paired in order to avoid - # offering a second discovery for the same device - if not is_homekit_paired(props) and not homekit_discovery.always_discover: - # If the device is paired with HomeKit we must send on - # the update to homekit_controller so it can see when - # the 'c#' field is updated. This is used to detect - # when the device has been reset or updated. - # - # If the device is not paired and we should not always - # discover it, we can stop here. - return - - if not (matchers := self.zeroconf_types.get(service_type)): - return - - # Not all homekit types are currently used for discovery - # so not all service type exist in zeroconf_types - for matcher in matchers: - if len(matcher) > 1: - if ATTR_NAME in matcher and not _memorized_fnmatch( - info.name.lower(), matcher[ATTR_NAME] - ): - continue - if ATTR_PROPERTIES in matcher and not _match_against_props( - matcher[ATTR_PROPERTIES], props - ): - continue - - matcher_domain = matcher[ATTR_DOMAIN] - # Create a type annotated regular dict since this is a hot path and creating - # a regular dict is slightly cheaper than calling ConfigFlowContext - context: config_entries.ConfigFlowContext = { - "source": config_entries.SOURCE_ZEROCONF, - } - if domain: - # Domain of integration that offers alternative API to handle - # this device. - context["alternative_domain"] = domain - - discovery_flow.async_create_flow( - self.hass, - matcher_domain, - context, - info, - discovery_key=discovery_key, - ) - - -def async_get_homekit_discovery( - homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], - homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], - props: dict[str, Any], -) -> HomeKitDiscoveredIntegration | None: - """Handle a HomeKit discovery. - - Return the domain to forward the discovery data to - """ - if not ( - model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER) - ) or not isinstance(model, str): - return None - - for split_str in _HOMEKIT_MODEL_SPLITS: - key = (model.split(split_str))[0] if split_str else model - if discovery := homekit_model_lookups.get(key): - return discovery - - for pattern, discovery in homekit_model_matchers.items(): - if pattern.match(model): - return discovery - - return 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 - # for property keys and values - if not (maybe_ip_addresses := service.ip_addresses_by_version(IPVersion.All)): - return None - if TYPE_CHECKING: - ip_addresses = cast(list[IPv4Address | IPv6Address], maybe_ip_addresses) - else: - ip_addresses = maybe_ip_addresses - ip_address: IPv4Address | IPv6Address | None = None - for ip_addr in ip_addresses: - if not ip_addr.is_link_local and not ip_addr.is_unspecified: - ip_address = ip_addr - break - if not ip_address: - return None - - if TYPE_CHECKING: - 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, - hostname=service.server, - type=service.type, - name=service.name, - properties=service.decoded_properties, - ) - - def _suppress_invalid_properties(properties: dict) -> None: """Suppress any properties that will cause zeroconf to fail to startup.""" @@ -644,27 +307,6 @@ def _truncate_location_name_to_valid(location_name: str) -> str: return location_name.encode("utf-8")[:MAX_NAME_LEN].decode("utf-8", "ignore") -@lru_cache(maxsize=4096, typed=True) -def _compile_fnmatch(pattern: str) -> re.Pattern: - """Compile a fnmatch pattern.""" - return re.compile(translate(pattern)) - - -@lru_cache(maxsize=1024, typed=True) -def _memorized_fnmatch(name: str, pattern: str) -> bool: - """Memorized version of fnmatch that has a larger lru_cache. - - The default version of fnmatch only has a lru_cache of 256 entries. - With many devices we quickly reach that limit and end up compiling - the same pattern over and over again. - - Zeroconf has its own memorized fnmatch with its own lru_cache - since the data is going to be relatively the same - 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( diff --git a/homeassistant/components/zeroconf/const.py b/homeassistant/components/zeroconf/const.py new file mode 100644 index 00000000000..6267d18642c --- /dev/null +++ b/homeassistant/components/zeroconf/const.py @@ -0,0 +1,7 @@ +"""Zeroconf constants.""" + +DOMAIN = "zeroconf" + +ZEROCONF_TYPE = "_home-assistant._tcp.local." + +REQUEST_TIMEOUT = 10000 # 10 seconds diff --git a/homeassistant/components/zeroconf/discovery.py b/homeassistant/components/zeroconf/discovery.py new file mode 100644 index 00000000000..e9b4508caee --- /dev/null +++ b/homeassistant/components/zeroconf/discovery.py @@ -0,0 +1,410 @@ +"""Zeroconf discovery for Home Assistant.""" + +from __future__ import annotations + +from collections.abc import Callable +import contextlib +from fnmatch import translate +from functools import lru_cache, partial +from ipaddress import IPv4Address, IPv6Address +import logging +import re +from typing import TYPE_CHECKING, Any, Final, cast + +from zeroconf import BadTypeInNameException, IPVersion, ServiceStateChange +from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import discovery_flow +from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.service_info.zeroconf import ( + ZeroconfServiceInfo as _ZeroconfServiceInfo, +) +from homeassistant.loader import HomeKitDiscoveredIntegration, ZeroconfMatcher +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN, REQUEST_TIMEOUT + +if TYPE_CHECKING: + from .models import HaZeroconf + +_LOGGER = logging.getLogger(__name__) + +ZEROCONF_TYPE = "_home-assistant._tcp.local." +HOMEKIT_TYPES = [ + "_hap._tcp.local.", + # Thread based devices + "_hap._udp.local.", +] +_HOMEKIT_MODEL_SPLITS = (None, " ", "-") + + +HOMEKIT_PAIRED_STATUS_FLAG = "sf" +HOMEKIT_MODEL_LOWER = "md" +HOMEKIT_MODEL_UPPER = "MD" + +ATTR_DOMAIN: Final = "domain" +ATTR_NAME: Final = "name" +ATTR_PROPERTIES: Final = "properties" + + +DATA_DISCOVERY: HassKey[ZeroconfDiscovery] = HassKey("zeroconf_discovery") + + +def build_homekit_model_lookups( + homekit_models: dict[str, HomeKitDiscoveredIntegration], +) -> tuple[ + dict[str, HomeKitDiscoveredIntegration], + dict[re.Pattern, HomeKitDiscoveredIntegration], +]: + """Build lookups for homekit models.""" + homekit_model_lookup: dict[str, HomeKitDiscoveredIntegration] = {} + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration] = {} + + for model, discovery in homekit_models.items(): + if "*" in model or "?" in model or "[" in model: + homekit_model_matchers[_compile_fnmatch(model)] = discovery + else: + homekit_model_lookup[model] = discovery + + return homekit_model_lookup, homekit_model_matchers + + +@lru_cache(maxsize=4096, typed=True) +def _compile_fnmatch(pattern: str) -> re.Pattern: + """Compile a fnmatch pattern.""" + return re.compile(translate(pattern)) + + +@lru_cache(maxsize=1024, typed=True) +def _memorized_fnmatch(name: str, pattern: str) -> bool: + """Memorized version of fnmatch that has a larger lru_cache. + + The default version of fnmatch only has a lru_cache of 256 entries. + With many devices we quickly reach that limit and end up compiling + the same pattern over and over again. + + Zeroconf has its own memorized fnmatch with its own lru_cache + since the data is going to be relatively the same + since the devices will not change frequently + """ + return bool(_compile_fnmatch(pattern).match(name)) + + +def _match_against_props(matcher: dict[str, str], props: dict[str, str | None]) -> bool: + """Check a matcher to ensure all values in props.""" + for key, value in matcher.items(): + prop_val = props.get(key) + if prop_val is None or not _memorized_fnmatch(prop_val.lower(), value): + return False + return True + + +def is_homekit_paired(props: dict[str, Any]) -> bool: + """Check properties to see if a device is homekit paired.""" + if HOMEKIT_PAIRED_STATUS_FLAG not in props: + return False + with contextlib.suppress(ValueError): + # 0 means paired and not discoverable by iOS clients) + return int(props[HOMEKIT_PAIRED_STATUS_FLAG]) == 0 + # If we cannot tell, we assume its not paired + return False + + +def async_get_homekit_discovery( + homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], + props: dict[str, Any], +) -> HomeKitDiscoveredIntegration | None: + """Handle a HomeKit discovery. + + Return the domain to forward the discovery data to + """ + if not ( + model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER) + ) or not isinstance(model, str): + return None + + for split_str in _HOMEKIT_MODEL_SPLITS: + key = (model.split(split_str))[0] if split_str else model + if discovery := homekit_model_lookups.get(key): + return discovery + + for pattern, discovery in homekit_model_matchers.items(): + if pattern.match(model): + return discovery + + return 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 + # for property keys and values + if not (maybe_ip_addresses := service.ip_addresses_by_version(IPVersion.All)): + return None + if TYPE_CHECKING: + ip_addresses = cast(list[IPv4Address | IPv6Address], maybe_ip_addresses) + else: + ip_addresses = maybe_ip_addresses + ip_address: IPv4Address | IPv6Address | None = None + for ip_addr in ip_addresses: + if not ip_addr.is_link_local and not ip_addr.is_unspecified: + ip_address = ip_addr + break + if not ip_address: + return None + + if TYPE_CHECKING: + 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, + hostname=service.server, + type=service.type, + name=service.name, + properties=service.decoded_properties, + ) + + +class ZeroconfDiscovery: + """Discovery via zeroconf.""" + + def __init__( + self, + hass: HomeAssistant, + zeroconf: HaZeroconf, + zeroconf_types: dict[str, list[ZeroconfMatcher]], + homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], + ) -> None: + """Init discovery.""" + self.hass = hass + self.zeroconf = zeroconf + self.zeroconf_types = zeroconf_types + self.homekit_model_lookups = homekit_model_lookups + self.homekit_model_matchers = homekit_model_matchers + self.async_service_browser: AsyncServiceBrowser | None = None + self._service_update_listeners: set[Callable[[AsyncServiceInfo], None]] = set() + self._service_removed_listeners: set[Callable[[str], None]] = set() + + @callback + def async_register_service_update_listener( + self, + listener: Callable[[AsyncServiceInfo], None], + ) -> Callable[[], None]: + """Register a service update listener.""" + self._service_update_listeners.add(listener) + return partial(self._service_update_listeners.remove, listener) + + @callback + def async_register_service_removed_listener( + self, + listener: Callable[[str], None], + ) -> Callable[[], None]: + """Register a service removed listener.""" + self._service_removed_listeners.add(listener) + return partial(self._service_removed_listeners.remove, listener) + + async def async_setup(self) -> None: + """Start discovery.""" + types = list(self.zeroconf_types) + # We want to make sure we know about other HomeAssistant + # instances as soon as possible to avoid name conflicts + # so we always browse for ZEROCONF_TYPE + types.extend( + hk_type + for hk_type in (ZEROCONF_TYPE, *HOMEKIT_TYPES) + if hk_type not in self.zeroconf_types + ) + _LOGGER.debug("Starting Zeroconf browser for: %s", types) + self.async_service_browser = AsyncServiceBrowser( + self.zeroconf, types, handlers=[self.async_service_update] + ) + + async_dispatcher_connect( + self.hass, + config_entries.signal_discovered_config_entry_removed(DOMAIN), + self._handle_config_entry_removed, + ) + + async def async_stop(self) -> None: + """Cancel the service browser and stop processing the queue.""" + if self.async_service_browser: + await self.async_service_browser.async_cancel() + + @callback + def _handle_config_entry_removed( + self, + entry: config_entries.ConfigEntry, + ) -> None: + """Handle config entry changes.""" + for discovery_key in entry.discovery_keys[DOMAIN]: + if discovery_key.version != 1: + continue + _type = discovery_key.key[0] + name = discovery_key.key[1] + _LOGGER.debug("Rediscover service %s.%s", _type, name) + self._async_service_update(self.zeroconf, _type, name) + + 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, + lambda service_info: bool(service_info.name == name), + ): + self.hass.config_entries.flow.async_abort(flow["flow_id"]) + + @callback + def async_service_update( + self, + zeroconf: HaZeroconf, + service_type: str, + name: str, + state_change: ServiceStateChange, + ) -> None: + """Service state changed.""" + _LOGGER.debug( + "service_update: type=%s name=%s state_change=%s", + service_type, + name, + state_change, + ) + + if state_change is ServiceStateChange.Removed: + self._async_dismiss_discoveries(name) + for listener in self._service_removed_listeners: + listener(name) + return + + self._async_service_update(zeroconf, service_type, name) + + @callback + def _async_service_update( + self, + zeroconf: HaZeroconf, + service_type: str, + name: str, + ) -> None: + """Service state added or changed.""" + try: + async_service_info = AsyncServiceInfo(service_type, name) + except BadTypeInNameException as ex: + # Some devices broadcast a name that is not a valid DNS name + # This is a bug in the device firmware and we should ignore it + _LOGGER.debug("Bad name in zeroconf record: %s: %s", name, ex) + return + + if async_service_info.load_from_cache(zeroconf): + self._async_process_service_update(async_service_info, service_type, name) + else: + self.hass.async_create_background_task( + self._async_lookup_and_process_service_update( + zeroconf, async_service_info, service_type, name + ), + name=f"zeroconf lookup {name}.{service_type}", + ) + + async def _async_lookup_and_process_service_update( + self, + zeroconf: HaZeroconf, + async_service_info: AsyncServiceInfo, + service_type: str, + name: str, + ) -> None: + """Update and process a zeroconf update.""" + await async_service_info.async_request(zeroconf, REQUEST_TIMEOUT) + self._async_process_service_update(async_service_info, service_type, name) + + @callback + def _async_process_service_update( + self, async_service_info: AsyncServiceInfo, service_type: str, name: str + ) -> None: + """Process a zeroconf update.""" + for listener in self._service_update_listeners: + listener(async_service_info) + info = info_from_service(async_service_info) + if not info: + # Prevent the browser thread from collapsing + _LOGGER.debug("Failed to get addresses for device %s", name) + return + _LOGGER.debug("Discovered new device %s %s", name, info) + props: dict[str, str | None] = info.properties + discovery_key = DiscoveryKey( + domain=DOMAIN, + key=(info.type, info.name), + version=1, + ) + domain = None + + # If we can handle it as a HomeKit discovery, we do that here. + if service_type in HOMEKIT_TYPES and ( + homekit_discovery := async_get_homekit_discovery( + self.homekit_model_lookups, self.homekit_model_matchers, props + ) + ): + domain = homekit_discovery.domain + discovery_flow.async_create_flow( + self.hass, + homekit_discovery.domain, + {"source": config_entries.SOURCE_HOMEKIT}, + info, + discovery_key=discovery_key, + ) + # Continue on here as homekit_controller + # still needs to get updates on devices + # so it can see when the 'c#' field is updated. + # + # We only send updates to homekit_controller + # if the device is already paired in order to avoid + # offering a second discovery for the same device + if not is_homekit_paired(props) and not homekit_discovery.always_discover: + # If the device is paired with HomeKit we must send on + # the update to homekit_controller so it can see when + # the 'c#' field is updated. This is used to detect + # when the device has been reset or updated. + # + # If the device is not paired and we should not always + # discover it, we can stop here. + return + + if not (matchers := self.zeroconf_types.get(service_type)): + return + + # Not all homekit types are currently used for discovery + # so not all service type exist in zeroconf_types + for matcher in matchers: + if len(matcher) > 1: + if ATTR_NAME in matcher and not _memorized_fnmatch( + info.name.lower(), matcher[ATTR_NAME] + ): + continue + if ATTR_PROPERTIES in matcher and not _match_against_props( + matcher[ATTR_PROPERTIES], props + ): + continue + + matcher_domain = matcher[ATTR_DOMAIN] + # Create a type annotated regular dict since this is a hot path and creating + # a regular dict is slightly cheaper than calling ConfigFlowContext + context: config_entries.ConfigFlowContext = { + "source": config_entries.SOURCE_ZEROCONF, + } + if domain: + # Domain of integration that offers alternative API to handle + # this device. + context["alternative_domain"] = domain + + discovery_flow.async_create_flow( + self.hass, + matcher_domain, + context, + info, + discovery_key=discovery_key, + ) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index a7fbfdfeada..fe190e78956 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.146.0"] + "requirements": ["zeroconf==0.147.0"] } diff --git a/homeassistant/components/zeroconf/websocket_api.py b/homeassistant/components/zeroconf/websocket_api.py new file mode 100644 index 00000000000..3a1881e6f4e --- /dev/null +++ b/homeassistant/components/zeroconf/websocket_api.py @@ -0,0 +1,163 @@ +"""The zeroconf integration websocket apis.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from functools import partial +from itertools import chain +import logging +from typing import Any, cast + +import voluptuous as vol +from zeroconf import BadTypeInNameException, DNSPointer, Zeroconf, current_time_millis +from zeroconf.asyncio import AsyncServiceInfo, IPVersion + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.json import json_bytes + +from .const import DOMAIN, REQUEST_TIMEOUT +from .discovery import DATA_DISCOVERY, ZeroconfDiscovery +from .models import HaAsyncZeroconf + +_LOGGER = logging.getLogger(__name__) +CLASS_IN = 1 +TYPE_PTR = 12 + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the zeroconf websocket API.""" + websocket_api.async_register_command(hass, ws_subscribe_discovery) + + +def serialize_service_info(service_info: AsyncServiceInfo) -> dict[str, Any]: + """Serialize an AsyncServiceInfo object.""" + return { + "name": service_info.name, + "type": service_info.type, + "port": service_info.port, + "properties": service_info.decoded_properties, + "ip_addresses": [ + str(ip) for ip in service_info.ip_addresses_by_version(IPVersion.All) + ], + } + + +class _DiscoverySubscription: + """Class to hold and manage the subscription data.""" + + def __init__( + self, + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + ws_msg_id: int, + aiozc: HaAsyncZeroconf, + discovery: ZeroconfDiscovery, + ) -> None: + """Initialize the subscription data.""" + self.hass = hass + self.discovery = discovery + self.aiozc = aiozc + self.ws_msg_id = ws_msg_id + self.connection = connection + + @callback + def _async_unsubscribe( + self, cancel_callbacks: tuple[Callable[[], None], ...] + ) -> None: + """Unsubscribe the callback.""" + for cancel_callback in cancel_callbacks: + cancel_callback() + + async def async_start(self) -> None: + """Start the subscription.""" + connection = self.connection + listeners = ( + self.discovery.async_register_service_update_listener( + self._async_on_update + ), + self.discovery.async_register_service_removed_listener( + self._async_on_remove + ), + ) + connection.subscriptions[self.ws_msg_id] = partial( + self._async_unsubscribe, listeners + ) + self.connection.send_message( + json_bytes(websocket_api.result_message(self.ws_msg_id)) + ) + await self._async_update_from_cache() + + async def _async_update_from_cache(self) -> None: + """Load the records from the cache.""" + tasks: list[asyncio.Task[None]] = [] + now = current_time_millis() + for record in self._async_get_ptr_records(self.aiozc.zeroconf): + try: + info = AsyncServiceInfo(record.name, record.alias) + except BadTypeInNameException as ex: + _LOGGER.debug( + "Ignoring record with bad type in name: %s: %s", record.alias, ex + ) + continue + if info.load_from_cache(self.aiozc.zeroconf, now): + self._async_on_update(info) + else: + tasks.append( + self.hass.async_create_background_task( + self._async_handle_service(info), + f"zeroconf resolve {record.alias}", + ), + ) + + if tasks: + await asyncio.gather(*tasks) + + def _async_get_ptr_records(self, zc: Zeroconf) -> list[DNSPointer]: + """Return all PTR records for the HAP type.""" + return cast( + list[DNSPointer], + list( + chain.from_iterable( + zc.cache.async_all_by_details(zc_type, TYPE_PTR, CLASS_IN) + for zc_type in self.discovery.zeroconf_types + ) + ), + ) + + async def _async_handle_service(self, info: AsyncServiceInfo) -> None: + """Add a device that became visible via zeroconf.""" + await info.async_request(self.aiozc.zeroconf, REQUEST_TIMEOUT) + self._async_on_update(info) + + 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_on_update(self, info: AsyncServiceInfo) -> None: + if info.type in self.discovery.zeroconf_types: + self._async_event_message({"add": [serialize_service_info(info)]}) + + def _async_on_remove(self, name: str) -> None: + self._async_event_message({"remove": [{"name": name}]}) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "zeroconf/subscribe_discovery", + } +) +@websocket_api.async_response +async def ws_subscribe_discovery( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe advertisements websocket command.""" + discovery = hass.data[DATA_DISCOVERY] + aiozc: HaAsyncZeroconf = hass.data[DOMAIN] + await _DiscoverySubscription( + hass, connection, msg["id"], aiozc, discovery + ).async_start() diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 234f10d59ae..6c5fcba1f8b 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -6,26 +6,14 @@ import dataclasses from importlib.metadata import version from typing import Any -from zha.application.const import ( - ATTR_ATTRIBUTE, - ATTR_DEVICE_TYPE, - ATTR_IEEE, - ATTR_IN_CLUSTERS, - ATTR_OUT_CLUSTERS, - ATTR_PROFILE_ID, - ATTR_VALUE, - UNKNOWN, -) +from zha.application.const import ATTR_IEEE from zha.application.gateway import Gateway -from zha.zigbee.device import Device from zigpy.config import CONF_NWK_EXTENDED_PAN_ID -from zigpy.profiles import PROFILES from zigpy.types import Channels -from zigpy.zcl import Cluster from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ID, CONF_NAME, CONF_UNIQUE_ID +from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -44,6 +32,7 @@ KEYS_TO_REDACT = { "network_key", CONF_NWK_EXTENDED_PAN_ID, "partner_ieee", + "device_ieee", } ATTRIBUTES = "attributes" @@ -122,60 +111,5 @@ async def async_get_device_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a device.""" zha_device_proxy: ZHADeviceProxy = async_get_zha_device_proxy(hass, device.id) - device_info: dict[str, Any] = zha_device_proxy.zha_device_info - device_info[CLUSTER_DETAILS] = get_endpoint_cluster_attr_data( - zha_device_proxy.device - ) - return async_redact_data(device_info, KEYS_TO_REDACT) - - -def get_endpoint_cluster_attr_data(zha_device: Device) -> dict: - """Return endpoint cluster attribute data.""" - cluster_details = {} - for ep_id, endpoint in zha_device.device.endpoints.items(): - if ep_id == 0: - continue - endpoint_key = ( - f"{PROFILES.get(endpoint.profile_id).DeviceType(endpoint.device_type).name}" - if PROFILES.get(endpoint.profile_id) is not None - and endpoint.device_type is not None - else UNKNOWN - ) - cluster_details[ep_id] = { - ATTR_DEVICE_TYPE: { - CONF_NAME: endpoint_key, - CONF_ID: endpoint.device_type, - }, - ATTR_PROFILE_ID: endpoint.profile_id, - ATTR_IN_CLUSTERS: { - f"0x{cluster_id:04x}": { - "endpoint_attribute": cluster.ep_attribute, - **get_cluster_attr_data(cluster), - } - for cluster_id, cluster in endpoint.in_clusters.items() - }, - ATTR_OUT_CLUSTERS: { - f"0x{cluster_id:04x}": { - "endpoint_attribute": cluster.ep_attribute, - **get_cluster_attr_data(cluster), - } - for cluster_id, cluster in endpoint.out_clusters.items() - }, - } - return cluster_details - - -def get_cluster_attr_data(cluster: Cluster) -> dict: - """Return cluster attribute data.""" - return { - ATTRIBUTES: { - f"0x{attr_id:04x}": { - ATTR_ATTRIBUTE: repr(attr_def), - ATTR_VALUE: cluster.get(attr_def.name), - } - for attr_id, attr_def in cluster.attributes.items() - }, - UNSUPPORTED_ATTRIBUTES: sorted( - cluster.unsupported_attributes, key=lambda v: (isinstance(v, str), v) - ), - } + diagnostics_json: dict[str, Any] = zha_device_proxy.device.get_diagnostics_json() + return async_redact_data(diagnostics_json, KEYS_TO_REDACT) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 04f3658d924..ae337c2a5f5 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.56"], + "requirements": ["zha==0.0.57"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index a8383857e57..73d773b1640 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -138,6 +138,11 @@ class Sensor(ZHAEntity, SensorEntity): entity_description.device_class.value ) + if entity.info_object.suggested_display_precision is not None: + self._attr_suggested_display_precision = ( + entity.info_object.suggested_display_precision + ) + @property def native_value(self) -> StateType: """Return the state of the entity.""" diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index a35dd50df54..d6a812569f5 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -1128,6 +1128,15 @@ }, "water_interval": { "name": "Water interval" + }, + "hush_duration": { + "name": "Hush duration" + }, + "temperature_control_accuracy": { + "name": "Temperature control accuracy" + }, + "external_temperature_sensor_value": { + "name": "External temperature sensor value" } }, "select": { @@ -1349,6 +1358,15 @@ }, "speed": { "name": "Speed" + }, + "led_brightness": { + "name": "LED brightness" + }, + "alarm_sound_level": { + "name": "Alarm sound level" + }, + "alarm_sound_mode": { + "name": "Alarm sound mode" } }, "sensor": { @@ -1487,7 +1505,7 @@ "adaptation_run_status": { "name": "Adaptation run status", "state": { - "nothing": "Idle", + "nothing": "[%key:common::state::idle%]", "something": "State" }, "state_attributes": { @@ -1509,7 +1527,7 @@ "name": "Software error", "state": { "nothing": "Good", - "something": "Error" + "something": "[%key:common::state::error%]" }, "state_attributes": { "top_pcb_sensor_error": { @@ -1590,7 +1608,7 @@ "name": "Floor temperature" }, "self_test": { - "name": "Self test result" + "name": "Self-test result" }, "voc_index": { "name": "VOC index" @@ -1699,6 +1717,9 @@ }, "device_status": { "name": "Device status" + }, + "lifetime": { + "name": "Lifetime" } }, "switch": { @@ -1841,7 +1862,7 @@ "name": "Mute siren" }, "self_test_switch": { - "name": "Self test" + "name": "Self-test" }, "output_switch": { "name": "Output switch" @@ -1908,6 +1929,12 @@ }, "auto_clean": { "name": "Auto clean" + }, + "test_mode": { + "name": "Test mode" + }, + "external_temperature_sensor": { + "name": "External temperature sensor" } } } diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 813425c95f2..6325f830ea0 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable import logging from operator import attrgetter import sys @@ -47,6 +47,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.loader import bind_hass +from homeassistant.util.hass_dict import HassKey from homeassistant.util.location import distance from .const import ATTR_PASSIVE, ATTR_RADIUS, CONF_PASSIVE, DOMAIN, HOME_ZONE @@ -108,10 +109,13 @@ ENTITY_ID_SORTER = attrgetter("entity_id") ZONE_ENTITY_IDS = "zone_entity_ids" +DATA_ZONE_STORAGE_COLLECTION: HassKey[ZoneStorageCollection] = HassKey(DOMAIN) +DATA_ZONE_ENTITY_IDS: HassKey[list[str]] = HassKey(ZONE_ENTITY_IDS) + @bind_hass def async_active_zone( - hass: HomeAssistant, latitude: float, longitude: float, radius: int = 0 + hass: HomeAssistant, latitude: float, longitude: float, radius: float = 0 ) -> State | None: """Find the active zone for given latitude, longitude. @@ -122,7 +126,7 @@ def async_active_zone( closest: State | None = None # This can be called before async_setup by device tracker - zone_entity_ids: Iterable[str] = hass.data.get(ZONE_ENTITY_IDS, ()) + zone_entity_ids = hass.data.get(DATA_ZONE_ENTITY_IDS, ()) for entity_id in zone_entity_ids: if ( @@ -168,8 +172,8 @@ def async_active_zone( @callback def async_setup_track_zone_entity_ids(hass: HomeAssistant) -> None: """Set up track of entity IDs for zones.""" - zone_entity_ids: list[str] = hass.states.async_entity_ids(DOMAIN) - hass.data[ZONE_ENTITY_IDS] = zone_entity_ids + zone_entity_ids = hass.states.async_entity_ids(DOMAIN) + hass.data[DATA_ZONE_ENTITY_IDS] = zone_entity_ids @callback def _async_add_zone_entity_id( @@ -290,7 +294,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, core_config_updated) - hass.data[DOMAIN] = storage_collection + hass.data[DATA_ZONE_STORAGE_COLLECTION] = storage_collection return True @@ -312,13 +316,11 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry ) -> bool: """Set up zone as config entry.""" - storage_collection = cast(ZoneStorageCollection, hass.data[DOMAIN]) - data = dict(config_entry.data) data.setdefault(CONF_PASSIVE, DEFAULT_PASSIVE) data.setdefault(CONF_RADIUS, DEFAULT_RADIUS) - await storage_collection.async_create_item(data) + await hass.data[DATA_ZONE_STORAGE_COLLECTION].async_create_item(data) hass.async_create_task( hass.config_entries.async_remove(config_entry.entry_id), eager_start=True diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index a7b8f9ed665..e73bd01deba 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -363,11 +363,17 @@ class DriverEvents: self.dev_reg.async_get_device(identifiers={get_device_id(driver, node)}) for node in controller.nodes.values() ] + provisioned_devices = [ + self.dev_reg.async_get(entry.additional_properties["device_id"]) + for entry in await controller.async_get_provisioning_entries() + if entry.additional_properties + and "device_id" in entry.additional_properties + ] # Devices that are in the device registry that are not known by the controller # can be removed for device in stored_devices: - if device not in known_devices: + if device not in known_devices and device not in provisioned_devices: self.dev_reg.async_remove_device(device.id) # run discovery on controller node @@ -448,6 +454,8 @@ class ControllerEvents: ) ) + await self.async_check_preprovisioned_device(node) + if node.is_controller_node: # Create a controller status sensor for each device async_dispatcher_send( @@ -497,7 +505,7 @@ class ControllerEvents: # we do submit the node to device registry so user has # some visual feedback that something is (in the process of) being added - self.register_node_in_dev_reg(node) + await self.async_register_node_in_dev_reg(node) @callback def async_on_node_removed(self, event: dict) -> None: @@ -574,18 +582,52 @@ class ControllerEvents: f"{DOMAIN}.identify_controller.{dev_id[1]}", ) - @callback - def register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry: + async def async_check_preprovisioned_device(self, node: ZwaveNode) -> None: + """Check if the node was preprovisioned and update the device registry.""" + provisioning_entry = ( + await self.driver_events.driver.controller.async_get_provisioning_entry( + node.node_id + ) + ) + if ( + provisioning_entry + and provisioning_entry.additional_properties + and "device_id" in provisioning_entry.additional_properties + ): + preprovisioned_device = self.dev_reg.async_get( + provisioning_entry.additional_properties["device_id"] + ) + + if preprovisioned_device: + dsk = provisioning_entry.dsk + dsk_identifier = (DOMAIN, f"provision_{dsk}") + + # If the pre-provisioned device has the DSK identifier, remove it + if dsk_identifier in preprovisioned_device.identifiers: + driver = self.driver_events.driver + device_id = get_device_id(driver, node) + device_id_ext = get_device_id_ext(driver, node) + new_identifiers = preprovisioned_device.identifiers.copy() + new_identifiers.remove(dsk_identifier) + new_identifiers.add(device_id) + if device_id_ext: + new_identifiers.add(device_id_ext) + self.dev_reg.async_update_device( + preprovisioned_device.id, + new_identifiers=new_identifiers, + ) + + async def async_register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry: """Register node in dev reg.""" driver = self.driver_events.driver device_id = get_device_id(driver, node) device_id_ext = get_device_id_ext(driver, node) node_id_device = self.dev_reg.async_get_device(identifiers={device_id}) - via_device_id = None + via_identifier = None controller = driver.controller # Get the controller node device ID if this node is not the controller if controller.own_node and controller.own_node != node: - via_device_id = get_device_id(driver, controller.own_node) + via_identifier = get_device_id(driver, controller.own_node) if device_id_ext: # If there is a device with this node ID but with a different hardware @@ -632,7 +674,7 @@ class ControllerEvents: model=node.device_config.label, manufacturer=node.device_config.manufacturer, suggested_area=node.location if node.location else UNDEFINED, - via_device=via_device_id, + via_device=via_identifier, ) async_dispatcher_send(self.hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device) @@ -666,7 +708,7 @@ class NodeEvents: """Handle node ready event.""" LOGGER.debug("Processing node %s", node) # register (or update) node in device registry - device = self.controller_events.register_node_in_dev_reg(node) + device = await self.controller_events.async_register_node_in_dev_reg(node) # Remove any old value ids if this is a reinterview. self.controller_events.discovered_value_ids.pop(device.id, None) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index dd698d9ed66..aa2219031d2 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -2,7 +2,9 @@ from __future__ import annotations +import asyncio from collections.abc import Callable, Coroutine +from contextlib import suppress import dataclasses from functools import partial, wraps from typing import Any, Concatenate, Literal, cast @@ -91,6 +93,7 @@ from .const import ( from .helpers import ( async_enable_statistics, async_get_node_from_device_id, + async_get_provisioning_entry_from_device_id, get_device_id, ) @@ -171,12 +174,18 @@ ADDITIONAL_PROPERTIES = "additional_properties" STATUS = "status" REQUESTED_SECURITY_CLASSES = "requestedSecurityClasses" +PROTOCOL = "protocol" +DEVICE_NAME = "device_name" +AREA_ID = "area_id" + FEATURE = "feature" STRATEGY = "strategy" # https://github.com/zwave-js/node-zwave-js/blob/master/packages/core/src/security/QR.ts#L41 MINIMUM_QR_STRING_LENGTH = 52 +HARD_RESET_CONTROLLER_DRIVER_READY_TIMEOUT = 60 + # Helper schemas PLANNED_PROVISIONING_ENTRY_SCHEMA = vol.All( @@ -398,6 +407,7 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_subscribe_s2_inclusion) websocket_api.async_register_command(hass, websocket_grant_security_classes) websocket_api.async_register_command(hass, websocket_validate_dsk_and_enter_pin) + websocket_api.async_register_command(hass, websocket_subscribe_new_devices) websocket_api.async_register_command(hass, websocket_provision_smart_start_node) websocket_api.async_register_command(hass, websocket_unprovision_smart_start_node) websocket_api.async_register_command(hass, websocket_get_provisioning_entries) @@ -631,14 +641,38 @@ async def websocket_node_metadata( } ) @websocket_api.async_response -@async_get_node async def websocket_node_alerts( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - node: Node, ) -> None: """Get the alerts for a Z-Wave JS node.""" + try: + node = async_get_node_from_device_id(hass, msg[DEVICE_ID]) + except ValueError as err: + if "can't be found" in err.args[0]: + provisioning_entry = await async_get_provisioning_entry_from_device_id( + hass, msg[DEVICE_ID] + ) + if provisioning_entry: + connection.send_result( + msg[ID], + { + "comments": [ + { + "level": "info", + "text": "This device has been provisioned but is not yet included in the " + "network.", + } + ], + }, + ) + else: + connection.send_error(msg[ID], ERR_NOT_FOUND, str(err)) + else: + connection.send_error(msg[ID], ERR_NOT_LOADED, str(err)) + return + connection.send_result( msg[ID], { @@ -971,12 +1005,58 @@ async def websocket_validate_dsk_and_enter_pin( connection.send_result(msg[ID]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/subscribe_new_devices", + vol.Required(ENTRY_ID): str, + } +) +@websocket_api.async_response +async def websocket_subscribe_new_devices( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], +) -> None: + """Subscribe to new devices.""" + + @callback + def async_cleanup() -> None: + for unsub in unsubs: + unsub() + + @callback + def device_registered(device: dr.DeviceEntry) -> None: + device_details = { + "name": device.name, + "id": device.id, + "manufacturer": device.manufacturer, + "model": device.model, + } + connection.send_message( + websocket_api.event_message( + msg[ID], {"event": "device registered", "device": device_details} + ) + ) + + connection.subscriptions[msg["id"]] = async_cleanup + msg[DATA_UNSUBSCRIBE] = unsubs = [ + async_dispatcher_connect( + hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device_registered + ), + ] + connection.send_result(msg[ID]) + + @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required(TYPE): "zwave_js/provision_smart_start_node", vol.Required(ENTRY_ID): str, vol.Required(QR_PROVISIONING_INFORMATION): QR_PROVISIONING_INFORMATION_SCHEMA, + vol.Optional(PROTOCOL): vol.Coerce(Protocols), + vol.Optional(DEVICE_NAME): str, + vol.Optional(AREA_ID): str, } ) @websocket_api.async_response @@ -991,18 +1071,68 @@ async def websocket_provision_smart_start_node( driver: Driver, ) -> None: """Pre-provision a smart start node.""" + qr_info = msg[QR_PROVISIONING_INFORMATION] - provisioning_info = msg[QR_PROVISIONING_INFORMATION] - - if provisioning_info.version == QRCodeVersion.S2: + if qr_info.version == QRCodeVersion.S2: connection.send_error( msg[ID], ERR_INVALID_FORMAT, "QR code version S2 is not supported for this command", ) return + + provisioning_info = ProvisioningEntry( + dsk=qr_info.dsk, + security_classes=qr_info.security_classes, + requested_security_classes=qr_info.requested_security_classes, + protocol=msg.get(PROTOCOL), + additional_properties=qr_info.additional_properties, + ) + + device = None + # Create an empty device if device_name is provided + if device_name := msg.get(DEVICE_NAME): + dev_reg = dr.async_get(hass) + + # Create a unique device identifier using the DSK + device_identifier = (DOMAIN, f"provision_{qr_info.dsk}") + + manufacturer = None + model = None + + device_info = await driver.config_manager.lookup_device( + qr_info.manufacturer_id, + qr_info.product_type, + qr_info.product_id, + ) + if device_info: + manufacturer = device_info.manufacturer + model = device_info.label + + # Create an empty device + device = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={device_identifier}, + name=device_name, + manufacturer=manufacturer, + model=model, + via_device=get_device_id(driver, driver.controller.own_node) + if driver.controller.own_node + else None, + ) + dev_reg.async_update_device( + device.id, area_id=msg.get(AREA_ID), name_by_user=device_name + ) + + if provisioning_info.additional_properties is None: + provisioning_info.additional_properties = {} + provisioning_info.additional_properties["device_id"] = device.id + await driver.controller.async_provision_smart_start_node(provisioning_info) - connection.send_result(msg[ID]) + if device: + connection.send_result(msg[ID], device.id) + else: + connection.send_result(msg[ID]) @websocket_api.require_admin @@ -1036,7 +1166,24 @@ async def websocket_unprovision_smart_start_node( ) return dsk_or_node_id = msg.get(DSK) or msg[NODE_ID] + provisioning_entry = await driver.controller.async_get_provisioning_entry( + dsk_or_node_id + ) + if ( + provisioning_entry + and provisioning_entry.additional_properties + and "device_id" in provisioning_entry.additional_properties + ): + device_identifier = (DOMAIN, f"provision_{provisioning_entry.dsk}") + device_id = provisioning_entry.additional_properties["device_id"] + dev_reg = dr.async_get(hass) + device = dev_reg.async_get(device_id) + if device and device.identifiers == {device_identifier}: + # Only remove the device if nothing else has claimed it + dev_reg.async_remove_device(device_id) + await driver.controller.async_unprovision_smart_start_node(dsk_or_node_id) + connection.send_result(msg[ID]) @@ -2673,6 +2820,7 @@ async def websocket_hard_reset_controller( driver: Driver, ) -> None: """Hard reset controller.""" + unsubs: list[Callable[[], None]] @callback def async_cleanup() -> None: @@ -2688,13 +2836,28 @@ async def websocket_hard_reset_controller( connection.send_result(msg[ID], device.id) async_cleanup() + @callback + def set_driver_ready(event: dict) -> None: + "Set the driver ready event." + wait_driver_ready.set() + + wait_driver_ready = asyncio.Event() + msg[DATA_UNSUBSCRIBE] = unsubs = [ async_dispatcher_connect( hass, EVENT_DEVICE_ADDED_TO_REGISTRY, _handle_device_added - ) + ), + driver.once("driver ready", set_driver_ready), ] + await driver.async_hard_reset() + with suppress(TimeoutError): + async with asyncio.timeout(HARD_RESET_CONTROLLER_DRIVER_READY_TIMEOUT): + await wait_driver_ready.wait() + + await hass.config_entries.async_reload(entry.entry_id) + @websocket_api.websocket_command( { diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index d07846c8dcc..1439aa0ca0f 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -67,7 +67,45 @@ class PropertyZWaveJSEntityDescription(BinarySensorEntityDescription): # Mappings for Notification sensors -# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/notifications.json +# https://github.com/zwave-js/specs/blob/master/Registries/Notification%20Command%20Class%2C%20list%20of%20assigned%20Notifications.xlsx +# +# Mapping rules: +# The catch all description should not have a device class and be marked as diagnostic. +# +# The following notifications have been moved to diagnostic: +# Smoke Alarm +# - Alarm silenced +# - Replacement required +# - Replacement required, End-of-life +# - Maintenance required, planned periodic inspection +# - Maintenance required, dust in device +# CO Alarm +# - Carbon monoxide test +# - Replacement required +# - Replacement required, End-of-life +# - Alarm silenced +# - Maintenance required, planned periodic inspection +# CO2 Alarm +# - Carbon dioxide test +# - Replacement required +# - Replacement required, End-of-life +# - Alarm silenced +# - Maintenance required, planned periodic inspection +# Heat Alarm +# - Rapid temperature rise (location provided) +# - Rapid temperature rise +# - Rapid temperature fall (location provided) +# - Rapid temperature fall +# - Heat alarm test +# - Alarm silenced +# - Replacement required, End-of-life +# - Maintenance required, dust in device +# - Maintenance required, planned periodic inspection + +# Water Alarm +# - Replace water filter +# - Sump pump failure + NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = ( NotificationZWaveJSEntityDescription( # NotificationType 1: Smoke Alarm - State Id's 1 and 2 - Smoke detected @@ -75,10 +113,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = states=("1", "2"), device_class=BinarySensorDeviceClass.SMOKE, ), + NotificationZWaveJSEntityDescription( + # NotificationType 1: Smoke Alarm - State Id's 4, 5, 7, 8 + key=NOTIFICATION_SMOKE_ALARM, + states=("4", "5", "7", "8"), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 1: Smoke Alarm - All other State Id's key=NOTIFICATION_SMOKE_ALARM, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 2: Carbon Monoxide - State Id's 1 and 2 @@ -86,10 +131,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = states=("1", "2"), device_class=BinarySensorDeviceClass.CO, ), + NotificationZWaveJSEntityDescription( + # NotificationType 2: Carbon Monoxide - State Id 4, 5, 7 + key=NOTIFICATION_CARBON_MONOOXIDE, + states=("4", "5", "7"), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 2: Carbon Monoxide - All other State Id's key=NOTIFICATION_CARBON_MONOOXIDE, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 3: Carbon Dioxide - State Id's 1 and 2 @@ -97,10 +149,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = states=("1", "2"), device_class=BinarySensorDeviceClass.GAS, ), + NotificationZWaveJSEntityDescription( + # NotificationType 3: Carbon Dioxide - State Id's 4, 5, 7 + key=NOTIFICATION_CARBON_DIOXIDE, + states=("4", "5", "7"), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 3: Carbon Dioxide - All other State Id's key=NOTIFICATION_CARBON_DIOXIDE, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 4: Heat - State Id's 1, 2, 5, 6 (heat/underheat) @@ -109,20 +168,34 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = device_class=BinarySensorDeviceClass.HEAT, ), NotificationZWaveJSEntityDescription( - # NotificationType 4: Heat - All other State Id's + # NotificationType 4: Heat - State ID's 8, A, B key=NOTIFICATION_HEAT, + states=("8", "10", "11"), device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( - # NotificationType 5: Water - State Id's 1, 2, 3, 4 + # NotificationType 4: Heat - All other State Id's + key=NOTIFICATION_HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NotificationZWaveJSEntityDescription( + # NotificationType 5: Water - State Id's 1, 2, 3, 4, 6, 7, 8, 9, 0A key=NOTIFICATION_WATER, - states=("1", "2", "3", "4"), + states=("1", "2", "3", "4", "6", "7", "8", "9", "10"), device_class=BinarySensorDeviceClass.MOISTURE, ), + NotificationZWaveJSEntityDescription( + # NotificationType 5: Water - State Id's B + key=NOTIFICATION_WATER, + states=("11",), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 5: Water - All other State Id's key=NOTIFICATION_WATER, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 6: Access Control - State Id's 1, 2, 3, 4 (Lock) @@ -214,16 +287,22 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = device_class=BinarySensorDeviceClass.SOUND, ), NotificationZWaveJSEntityDescription( - # NotificationType 18: Gas + # NotificationType 18: Gas - State Id's 1, 2, 3, 4 key=NOTIFICATION_GAS, states=("1", "2", "3", "4"), device_class=BinarySensorDeviceClass.GAS, ), NotificationZWaveJSEntityDescription( - # NotificationType 18: Gas + # NotificationType 18: Gas - State Id 6 key=NOTIFICATION_GAS, states=("6",), device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NotificationZWaveJSEntityDescription( + # NotificationType 18: Gas - All other State Id's + key=NOTIFICATION_GAS, + entity_category=EntityCategory.DIAGNOSTIC, ), ) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index aed0dd839be..84717047fdd 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -2,14 +2,20 @@ from __future__ import annotations -from abc import ABC, abstractmethod import asyncio +from contextlib import suppress +from datetime import datetime import logging +from pathlib import Path from typing import Any import aiohttp +from awesomeversion import AwesomeVersion from serial.tools import list_ports import voluptuous as vol +from zwave_js_server.client import Client +from zwave_js_server.exceptions import FailedCommand +from zwave_js_server.model.driver import Driver from zwave_js_server.version import VersionInfo, get_server_version from homeassistant.components import usb @@ -21,19 +27,14 @@ from homeassistant.components.hassio import ( ) from homeassistant.config_entries import ( SOURCE_USB, - ConfigEntriesFlowManager, ConfigEntry, - ConfigEntryBaseFlow, ConfigEntryState, ConfigFlow, - ConfigFlowContext, ConfigFlowResult, - OptionsFlow, - OptionsFlowManager, ) from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import AbortFlow, FlowManager +from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio @@ -64,6 +65,7 @@ from .const import ( CONF_S2_UNAUTHENTICATED_KEY, CONF_USB_PATH, CONF_USE_ADDON, + DATA_CLIENT, DOMAIN, ) @@ -76,6 +78,7 @@ ADDON_SETUP_TIMEOUT = 5 ADDON_SETUP_TIMEOUT_ROUNDS = 40 CONF_EMULATE_HARDWARE = "emulate_hardware" CONF_LOG_LEVEL = "log_level" +RESTORE_NVM_DRIVER_READY_TIMEOUT = 60 SERVER_VERSION_TIMEOUT = 10 ADDON_LOG_LEVELS = { @@ -99,6 +102,7 @@ ADDON_USER_INPUT_MAP = { } ON_SUPERVISOR_SCHEMA = vol.Schema({vol.Optional(CONF_USE_ADDON, default=True): bool}) +MIN_MIGRATION_SDK_VERSION = AwesomeVersion("6.61") def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema: @@ -171,8 +175,12 @@ async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: return await hass.async_add_executor_job(get_usb_ports) -class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC): - """Represent the base config flow for Z-Wave JS.""" +class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Z-Wave JS.""" + + VERSION = 1 + + _title: str def __init__(self) -> None: """Set up flow instance.""" @@ -190,11 +198,16 @@ class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC): self.install_task: asyncio.Task | None = None self.start_task: asyncio.Task | None = None self.version_info: VersionInfo | None = None - - @property - @abstractmethod - def flow_manager(self) -> FlowManager[ConfigFlowContext, ConfigFlowResult]: - """Return the flow manager of the flow.""" + self.original_addon_config: dict[str, Any] | None = None + self.revert_reason: str | None = None + self.backup_task: asyncio.Task | None = None + self.restore_backup_task: asyncio.Task | None = None + self.backup_data: bytes | None = None + self.backup_filepath: str | None = None + self.use_addon = False + self._migrating = False + self._reconfigure_config_entry: ConfigEntry | None = None + self._usb_discovery = False async def async_step_install_addon( self, user_input: dict[str, Any] | None = None @@ -256,6 +269,10 @@ class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Add-on start failed.""" + if self._migrating: + return self.async_abort(reason="addon_start_failed") + if self._reconfigure_config_entry: + return await self.async_revert_addon_config(reason="addon_start_failed") return self.async_abort(reason="addon_start_failed") async def _async_start_addon(self) -> None: @@ -289,13 +306,14 @@ class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC): else: raise CannotConnect("Failed to start Z-Wave JS add-on: timeout") - @abstractmethod async def async_step_configure_addon( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Ask for config for Z-Wave JS add-on.""" + if self._reconfigure_config_entry: + return await self.async_step_configure_addon_reconfigure(user_input) + return await self.async_step_configure_addon_user(user_input) - @abstractmethod async def async_step_finish_addon_setup( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -304,6 +322,11 @@ class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC): Get add-on discovery info and server version info. Set unique id and abort if already configured. """ + if self._migrating: + return await self.async_step_finish_addon_setup_migrate(user_input) + if self._reconfigure_config_entry: + return await self.async_step_finish_addon_setup_reconfigure(user_input) + return await self.async_step_finish_addon_setup_user(user_input) async def _async_get_addon_info(self) -> AddonInfo: """Return and cache Z-Wave JS add-on info.""" @@ -316,11 +339,25 @@ class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC): return addon_info - async def _async_set_addon_config(self, config: dict) -> None: + async def _async_set_addon_config(self, config_updates: dict) -> None: """Set Z-Wave JS add-on config.""" + addon_info = await self._async_get_addon_info() + addon_config = addon_info.options + + new_addon_config = addon_config | config_updates + + if new_addon_config == addon_config: + return + + if addon_info.state == AddonState.RUNNING: + self.restart_addon = True + # Copy the add-on config to keep the objects separate. + self.original_addon_config = dict(addon_config) + # Remove legacy network_key + new_addon_config.pop(CONF_ADDON_NETWORK_KEY, None) addon_manager: AddonManager = get_addon_manager(self.hass) try: - await addon_manager.async_set_addon_options(config) + await addon_manager.async_set_addon_options(new_addon_config) except AddonError as err: _LOGGER.error(err) raise AbortFlow("addon_set_config_failed") from err @@ -341,33 +378,6 @@ class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC): return discovery_info_config - -class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): - """Handle a config flow for Z-Wave JS.""" - - VERSION = 1 - - _title: str - - def __init__(self) -> None: - """Set up flow instance.""" - super().__init__() - self.use_addon = False - self._usb_discovery = False - - @property - def flow_manager(self) -> ConfigEntriesFlowManager: - """Return the correct flow manager.""" - return self.hass.config_entries.flow - - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlowHandler: - """Return the options flow.""" - return OptionsFlowHandler() - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -377,6 +387,19 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): return await self.async_step_manual() + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm if we are migrating adapters or just re-configuring.""" + self._reconfigure_config_entry = self._get_reconfigure_entry() + return self.async_show_menu( + step_id="reconfigure", + menu_options=[ + "intent_reconfigure", + "intent_migrate", + ], + ) + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: @@ -409,10 +432,27 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): """Handle USB Discovery.""" if not is_hassio(self.hass): return self.async_abort(reason="discovery_requires_supervisor") - if self._async_current_entries(): - return self.async_abort(reason="already_configured") - if self._async_in_progress(): + if any( + flow + for flow in self._async_in_progress() + if flow["context"].get("source") != SOURCE_USB + ): + # Allow multiple USB discovery flows to be in progress. + # Migration requires more than one USB stick to be connected, + # which can cause more than one discovery flow to be in progress, + # at least for a short time. return self.async_abort(reason="already_in_progress") + if current_config_entries := self._async_current_entries(include_ignore=False): + config_entry = next( + ( + entry + for entry in current_config_entries + if entry.data.get(CONF_USE_ADDON) + ), + None, + ) + if not config_entry: + return self.async_abort(reason="addon_required") vid = discovery_info.vid pid = discovery_info.pid @@ -423,27 +463,44 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): if vid == "10C4" and pid == "EA60" and description and "2652" in description: return self.async_abort(reason="not_zwave_device") + discovery_info.device = await self.hass.async_add_executor_job( + usb.get_serial_by_id, discovery_info.device + ) + addon_info = await self._async_get_addon_info() - if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.NOT_RUNNING): + if ( + addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.INSTALLING) + and (addon_device := addon_info.options.get(CONF_ADDON_DEVICE)) is not None + and await self.hass.async_add_executor_job( + usb.get_serial_by_id, addon_device + ) + == discovery_info.device + ): return self.async_abort(reason="already_configured") await self.async_set_unique_id( f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" ) - self._abort_if_unique_id_configured() + # We don't need to check if the unique_id is already configured + # since we will update the unique_id before finishing the flow. + # The unique_id set above is just a temporary value to avoid + # duplicate discovery flows. dev_path = discovery_info.device self.usb_path = dev_path - self._title = usb.human_readable_device_name( - dev_path, - serial_number, - manufacturer, - description, - vid, - pid, - ) - self.context["title_placeholders"] = { - CONF_NAME: self._title.split(" - ")[0].strip() - } + if manufacturer == "Nabu Casa" and description == "ZWA-2 - Nabu Casa ZWA-2": + title = "Home Assistant Connect ZWA-2" + else: + human_name = usb.human_readable_device_name( + dev_path, + serial_number, + manufacturer, + description, + vid, + pid, + ) + title = human_name.split(" - ")[0].strip() + self.context["title_placeholders"] = {CONF_NAME: title} + self._title = title return await self.async_step_usb_confirm() async def async_step_usb_confirm( @@ -457,6 +514,18 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): ) self._usb_discovery = True + if current_config_entries := self._async_current_entries(include_ignore=False): + self._reconfigure_config_entry = next( + ( + entry + for entry in current_config_entries + if entry.data.get(CONF_USE_ADDON) + ), + None, + ) + if not self._reconfigure_config_entry: + return self.async_abort(reason="addon_required") + return await self.async_step_intent_migrate() return await self.async_step_on_supervisor({CONF_USE_ADDON: True}) @@ -569,14 +638,14 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): self.lr_s2_authenticated_key = addon_config.get( CONF_ADDON_LR_S2_AUTHENTICATED_KEY, "" ) - return await self.async_step_finish_addon_setup() + return await self.async_step_finish_addon_setup_user() if addon_info.state == AddonState.NOT_RUNNING: - return await self.async_step_configure_addon() + return await self.async_step_configure_addon_user() return await self.async_step_install_addon() - async def async_step_configure_addon( + async def async_step_configure_addon_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Ask for config for Z-Wave JS add-on.""" @@ -593,8 +662,7 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): if not self._usb_discovery: self.usb_path = user_input[CONF_USB_PATH] - new_addon_config = { - **addon_config, + addon_config_updates = { CONF_ADDON_DEVICE: self.usb_path, CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, @@ -604,8 +672,7 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, } - if new_addon_config != addon_config: - await self._async_set_addon_config(new_addon_config) + await self._async_set_addon_config(addon_config_updates) return await self.async_step_start_addon() @@ -647,7 +714,12 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): } if not self._usb_discovery: - ports = await async_get_usb_ports(self.hass) + try: + ports = await async_get_usb_ports(self.hass) + except OSError as err: + _LOGGER.error("Failed to get USB ports: %s", err) + return self.async_abort(reason="usb_ports_failed") + schema = { vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports), **schema, @@ -655,9 +727,11 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): data_schema = vol.Schema(schema) - return self.async_show_form(step_id="configure_addon", data_schema=data_schema) + return self.async_show_form( + step_id="configure_addon_user", data_schema=data_schema + ) - async def async_step_finish_addon_setup( + async def async_step_finish_addon_setup_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Prepare info needed to complete the config entry. @@ -719,45 +793,156 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): }, ) - -class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): - """Handle an options flow for Z-Wave JS.""" - - def __init__(self) -> None: - """Set up the options flow.""" - super().__init__() - self.original_addon_config: dict[str, Any] | None = None - self.revert_reason: str | None = None - - @property - def flow_manager(self) -> OptionsFlowManager: - """Return the correct flow manager.""" - return self.hass.config_entries.options - @callback - def _async_update_entry(self, data: dict[str, Any]) -> None: + def _async_update_entry( + self, updates: dict[str, Any], *, schedule_reload: bool = True + ) -> None: """Update the config entry with new data.""" - self.hass.config_entries.async_update_entry(self.config_entry, data=data) + config_entry = self._reconfigure_config_entry + assert config_entry is not None + self.hass.config_entries.async_update_entry( + config_entry, data=config_entry.data | updates + ) + if schedule_reload: + self.hass.config_entries.async_schedule_reload(config_entry.entry_id) - async def async_step_init( + async def async_step_intent_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" if is_hassio(self.hass): - return await self.async_step_on_supervisor() + return await self.async_step_on_supervisor_reconfigure() - return await self.async_step_manual() + return await self.async_step_manual_reconfigure() - async def async_step_manual( + async def async_step_intent_migrate( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the user wants to reset their current controller.""" + config_entry = self._reconfigure_config_entry + assert config_entry is not None + if not self._usb_discovery and not config_entry.data.get(CONF_USE_ADDON): + return self.async_abort(reason="addon_required") + + try: + driver = self._get_driver() + except AbortFlow: + return self.async_abort(reason="config_entry_not_loaded") + if ( + sdk_version := driver.controller.sdk_version + ) is not None and sdk_version < MIN_MIGRATION_SDK_VERSION: + _LOGGER.warning( + "Migration from this controller that has SDK version %s " + "is not supported. If possible, update the firmware " + "of the controller to a firmware built using SDK version %s or higher", + sdk_version, + MIN_MIGRATION_SDK_VERSION, + ) + return self.async_abort( + reason="migration_low_sdk_version", + description_placeholders={ + "ok_sdk_version": str(MIN_MIGRATION_SDK_VERSION) + }, + ) + + if user_input is not None: + self._migrating = True + return await self.async_step_backup_nvm() + + return self.async_show_form(step_id="intent_migrate") + + async def async_step_backup_nvm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Backup the current network.""" + if self.backup_task is None: + self.backup_task = self.hass.async_create_task(self._async_backup_network()) + + if not self.backup_task.done(): + return self.async_show_progress( + step_id="backup_nvm", + progress_action="backup_nvm", + progress_task=self.backup_task, + ) + + try: + await self.backup_task + except AbortFlow as err: + _LOGGER.error(err) + return self.async_show_progress_done(next_step_id="backup_failed") + finally: + self.backup_task = None + + return self.async_show_progress_done(next_step_id="instruct_unplug") + + async def async_step_restore_nvm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Restore the backup.""" + if self.restore_backup_task is None: + self.restore_backup_task = self.hass.async_create_task( + self._async_restore_network_backup() + ) + + if not self.restore_backup_task.done(): + return self.async_show_progress( + step_id="restore_nvm", + progress_action="restore_nvm", + progress_task=self.restore_backup_task, + ) + + try: + await self.restore_backup_task + except AbortFlow as err: + _LOGGER.error(err) + return self.async_show_progress_done(next_step_id="restore_failed") + finally: + self.restore_backup_task = None + + return self.async_show_progress_done(next_step_id="migration_done") + + async def async_step_instruct_unplug( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reset the current controller, and instruct the user to unplug it.""" + + if user_input is not None: + if self.usb_path: + # USB discovery was used, so the device is already known. + await self._async_set_addon_config({CONF_ADDON_DEVICE: self.usb_path}) + return await self.async_step_start_addon() + # Now that the old controller is gone, we can scan for serial ports again + return await self.async_step_choose_serial_port() + + # reset the old controller + try: + await self._get_driver().async_hard_reset() + except (AbortFlow, FailedCommand) as err: + _LOGGER.error("Failed to reset controller: %s", err) + return self.async_abort(reason="reset_failed") + + config_entry = self._reconfigure_config_entry + assert config_entry is not None + # Unload the config entry before asking the user to unplug the controller. + await self.hass.config_entries.async_unload(config_entry.entry_id) + + return self.async_show_form( + step_id="instruct_unplug", + description_placeholders={ + "file_path": str(self.backup_filepath), + }, + ) + + async def async_step_manual_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a manual configuration.""" + config_entry = self._reconfigure_config_entry + assert config_entry is not None if user_input is None: return self.async_show_form( - step_id="manual", - data_schema=get_manual_schema( - {CONF_URL: self.config_entry.data[CONF_URL]} - ), + step_id="manual_reconfigure", + data_schema=get_manual_schema({CONF_URL: config_entry.data[CONF_URL]}), ) errors = {} @@ -770,49 +955,65 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - if self.config_entry.unique_id != str(version_info.home_id): + if config_entry.unique_id != str(version_info.home_id): return self.async_abort(reason="different_device") # Make sure we disable any add-on handling # if the controller is reconfigured in a manual step. self._async_update_entry( { - **self.config_entry.data, **user_input, CONF_USE_ADDON: False, CONF_INTEGRATION_CREATED_ADDON: False, } ) - self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) - return self.async_create_entry(title=TITLE, data={}) + return self.async_abort(reason="reconfigure_successful") return self.async_show_form( - step_id="manual", data_schema=get_manual_schema(user_input), errors=errors + step_id="manual_reconfigure", + data_schema=get_manual_schema(user_input), + errors=errors, ) - async def async_step_on_supervisor( + async def async_step_on_supervisor_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle logic when on Supervisor host.""" + config_entry = self._reconfigure_config_entry + assert config_entry is not None if user_input is None: return self.async_show_form( - step_id="on_supervisor", + step_id="on_supervisor_reconfigure", data_schema=get_on_supervisor_schema( - {CONF_USE_ADDON: self.config_entry.data.get(CONF_USE_ADDON, True)} + {CONF_USE_ADDON: config_entry.data.get(CONF_USE_ADDON, True)} ), ) + if not user_input[CONF_USE_ADDON]: - return await self.async_step_manual() + if config_entry.data.get(CONF_USE_ADDON): + # Unload the config entry before stopping the add-on. + await self.hass.config_entries.async_unload(config_entry.entry_id) + addon_manager = get_addon_manager(self.hass) + _LOGGER.debug("Stopping Z-Wave JS add-on") + try: + await addon_manager.async_stop_addon() + except AddonError as err: + _LOGGER.error(err) + self.hass.config_entries.async_schedule_reload( + config_entry.entry_id + ) + raise AbortFlow("addon_stop_failed") from err + return await self.async_step_manual_reconfigure() addon_info = await self._async_get_addon_info() if addon_info.state == AddonState.NOT_INSTALLED: return await self.async_step_install_addon() - return await self.async_step_configure_addon() + return await self.async_step_configure_addon_reconfigure() - async def async_step_configure_addon( + async def async_step_configure_addon_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Ask for config for Z-Wave JS add-on.""" @@ -828,8 +1029,7 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): self.lr_s2_authenticated_key = user_input[CONF_LR_S2_AUTHENTICATED_KEY] self.usb_path = user_input[CONF_USB_PATH] - new_addon_config = { - **addon_config, + addon_config_updates = { CONF_ADDON_DEVICE: self.usb_path, CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, @@ -843,24 +1043,16 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): ), } - if new_addon_config != addon_config: - if addon_info.state == AddonState.RUNNING: - self.restart_addon = True - # Copy the add-on config to keep the objects separate. - self.original_addon_config = dict(addon_config) - # Remove legacy network_key - new_addon_config.pop(CONF_ADDON_NETWORK_KEY, None) - await self._async_set_addon_config(new_addon_config) + await self._async_set_addon_config(addon_config_updates) if addon_info.state == AddonState.RUNNING and not self.restart_addon: - return await self.async_step_finish_addon_setup() + return await self.async_step_finish_addon_setup_reconfigure() if ( - self.config_entry.data.get(CONF_USE_ADDON) - and self.config_entry.state == ConfigEntryState.LOADED - ): + config_entry := self._reconfigure_config_entry + ) and config_entry.data.get(CONF_USE_ADDON): # Disconnect integration before restarting add-on. - await self.hass.config_entries.async_unload(self.config_entry.entry_id) + await self.hass.config_entries.async_unload(config_entry.entry_id) return await self.async_step_start_addon() @@ -886,7 +1078,11 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): log_level = addon_config.get(CONF_ADDON_LOG_LEVEL, "info") emulate_hardware = addon_config.get(CONF_ADDON_EMULATE_HARDWARE, False) - ports = await async_get_usb_ports(self.hass) + try: + ports = await async_get_usb_ports(self.hass) + except OSError as err: + _LOGGER.error("Failed to get USB ports: %s", err) + return self.async_abort(reason="usb_ports_failed") data_schema = vol.Schema( { @@ -914,15 +1110,92 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): } ) - return self.async_show_form(step_id="configure_addon", data_schema=data_schema) + return self.async_show_form( + step_id="configure_addon_reconfigure", data_schema=data_schema + ) - async def async_step_start_failed( + async def async_step_choose_serial_port( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Add-on start failed.""" - return await self.async_revert_addon_config(reason="addon_start_failed") + """Choose a serial port.""" + if user_input is not None: + self.usb_path = user_input[CONF_USB_PATH] + await self._async_set_addon_config({CONF_ADDON_DEVICE: self.usb_path}) + return await self.async_step_start_addon() - async def async_step_finish_addon_setup( + try: + ports = await async_get_usb_ports(self.hass) + except OSError as err: + _LOGGER.error("Failed to get USB ports: %s", err) + return self.async_abort(reason="usb_ports_failed") + + data_schema = vol.Schema( + { + vol.Required(CONF_USB_PATH): vol.In(ports), + } + ) + return self.async_show_form( + step_id="choose_serial_port", data_schema=data_schema + ) + + async def async_step_backup_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Backup failed.""" + return self.async_abort(reason="backup_failed") + + async def async_step_restore_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Restore failed.""" + if user_input is not None: + return await self.async_step_restore_nvm() + + return self.async_show_form( + step_id="restore_failed", + description_placeholders={ + "file_path": str(self.backup_filepath), + }, + ) + + async def async_step_migration_done( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Migration done.""" + return self.async_abort(reason="migration_successful") + + async def async_step_finish_addon_setup_migrate( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Prepare info needed to complete the config entry update.""" + ws_address = self.ws_address + assert ws_address is not None + version_info = self.version_info + assert version_info is not None + + # We need to wait for the config entry to be reloaded, + # before restoring the backup. + # We will do this in the restore nvm progress task, + # to get a nicer user experience. + self._async_update_entry( + { + "unique_id": str(version_info.home_id), + CONF_URL: ws_address, + CONF_USB_PATH: self.usb_path, + CONF_S0_LEGACY_KEY: self.s0_legacy_key, + CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, + CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, + CONF_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, + CONF_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, + CONF_USE_ADDON: True, + CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, + }, + schedule_reload=False, + ) + return await self.async_step_restore_nvm() + + async def async_step_finish_addon_setup_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Prepare info needed to complete the config entry update. @@ -930,6 +1203,8 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): Get add-on discovery info and server version info. Check for same unique id and abort if not the same unique id. """ + config_entry = self._reconfigure_config_entry + assert config_entry is not None if self.revert_reason: self.original_addon_config = None reason = self.revert_reason @@ -948,12 +1223,11 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): except CannotConnect: return await self.async_revert_addon_config(reason="cannot_connect") - if self.config_entry.unique_id != str(self.version_info.home_id): + if config_entry.unique_id != str(self.version_info.home_id): return await self.async_revert_addon_config(reason="different_device") self._async_update_entry( { - **self.config_entry.data, CONF_URL: self.ws_address, CONF_USB_PATH: self.usb_path, CONF_S0_LEGACY_KEY: self.s0_legacy_key, @@ -966,9 +1240,8 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, } ) - # Always reload entry since we may have disconnected the client. - self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) - return self.async_create_entry(title=TITLE, data={}) + + return self.async_abort(reason="reconfigure_successful") async def async_revert_addon_config(self, reason: str) -> ConfigFlowResult: """Abort the options flow. @@ -983,7 +1256,9 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): ) if self.revert_reason or not self.original_addon_config: - self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) + config_entry = self._reconfigure_config_entry + assert config_entry is not None + self.hass.config_entries.async_schedule_reload(config_entry.entry_id) return self.async_abort(reason=reason) self.revert_reason = reason @@ -993,7 +1268,93 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): if addon_key in ADDON_USER_INPUT_MAP } _LOGGER.debug("Reverting add-on options, reason: %s", reason) - return await self.async_step_configure_addon(addon_config_input) + return await self.async_step_configure_addon_reconfigure(addon_config_input) + + async def _async_backup_network(self) -> None: + """Backup the current network.""" + + @callback + def forward_progress(event: dict) -> None: + """Forward progress events to frontend.""" + self.async_update_progress(event["bytesRead"] / event["total"]) + + controller = self._get_driver().controller + unsub = controller.on("nvm backup progress", forward_progress) + try: + self.backup_data = await controller.async_backup_nvm_raw() + except FailedCommand as err: + raise AbortFlow(f"Failed to backup network: {err}") from err + finally: + unsub() + + # save the backup to a file just in case + self.backup_filepath = self.hass.config.path( + f"zwavejs_nvm_backup_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.bin" + ) + try: + await self.hass.async_add_executor_job( + Path(self.backup_filepath).write_bytes, + self.backup_data, + ) + except OSError as err: + raise AbortFlow(f"Failed to save backup file: {err}") from err + + async def _async_restore_network_backup(self) -> None: + """Restore the backup.""" + assert self.backup_data is not None + config_entry = self._reconfigure_config_entry + assert config_entry is not None + + # Reload the config entry to reconnect the client after the addon restart + await self.hass.config_entries.async_reload(config_entry.entry_id) + + @callback + def forward_progress(event: dict) -> None: + """Forward progress events to frontend.""" + if event["event"] == "nvm convert progress": + # assume convert is 50% of the total progress + self.async_update_progress(event["bytesRead"] / event["total"] * 0.5) + elif event["event"] == "nvm restore progress": + # assume restore is the rest of the progress + self.async_update_progress( + event["bytesWritten"] / event["total"] * 0.5 + 0.5 + ) + + @callback + def set_driver_ready(event: dict) -> None: + "Set the driver ready event." + wait_driver_ready.set() + + driver = self._get_driver() + controller = driver.controller + wait_driver_ready = asyncio.Event() + unsubs = [ + controller.on("nvm convert progress", forward_progress), + controller.on("nvm restore progress", forward_progress), + driver.once("driver ready", set_driver_ready), + ] + try: + await controller.async_restore_nvm(self.backup_data) + except FailedCommand as err: + raise AbortFlow(f"Failed to restore network: {err}") from err + else: + with suppress(TimeoutError): + async with asyncio.timeout(RESTORE_NVM_DRIVER_READY_TIMEOUT): + await wait_driver_ready.wait() + await self.hass.config_entries.async_reload(config_entry.entry_id) + finally: + for unsub in unsubs: + unsub() + + def _get_driver(self) -> Driver: + """Get the driver from the config entry.""" + config_entry = self._reconfigure_config_entry + assert config_entry is not None + if config_entry.state != ConfigEntryState.LOADED: + raise AbortFlow("Configuration entry is not loaded") + client: Client = config_entry.runtime_data[DATA_CLIENT] + assert client.driver is not None + return client.driver class CannotConnect(HomeAssistantError): diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 8a90ebf6f88..ded87b590a4 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -15,7 +15,7 @@ from zwave_js_server.const import ( ConfigurationValueType, LogLevel, ) -from zwave_js_server.model.controller import Controller +from zwave_js_server.model.controller import Controller, ProvisioningEntry from zwave_js_server.model.driver import Driver from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.node import Node as ZwaveNode @@ -233,7 +233,7 @@ def get_home_and_node_id_from_device_entry( ), None, ) - if device_id is None: + if device_id is None or device_id.startswith("provision_"): return None id_ = device_id.split("-") return (id_[0], int(id_[1])) @@ -264,12 +264,12 @@ def async_get_node_from_device_id( ), None, ) - if entry and entry.state != ConfigEntryState.LOADED: - raise ValueError(f"Device {device_id} config entry is not loaded") if entry is None: raise ValueError( f"Device {device_id} is not from an existing zwave_js config entry" ) + if entry.state != ConfigEntryState.LOADED: + raise ValueError(f"Device {device_id} config entry is not loaded") client: ZwaveClient = entry.runtime_data[DATA_CLIENT] driver = client.driver @@ -289,6 +289,53 @@ def async_get_node_from_device_id( return driver.controller.nodes[node_id] +async def async_get_provisioning_entry_from_device_id( + hass: HomeAssistant, device_id: str +) -> ProvisioningEntry | None: + """Get provisioning entry from a device ID. + + Raises ValueError if device is invalid + """ + dev_reg = dr.async_get(hass) + + if not (device_entry := dev_reg.async_get(device_id)): + raise ValueError(f"Device ID {device_id} is not valid") + + # Use device config entry ID's to validate that this is a valid zwave_js device + # and to get the client + config_entry_ids = device_entry.config_entries + entry = next( + ( + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.entry_id in config_entry_ids + ), + None, + ) + if entry is None: + raise ValueError( + f"Device {device_id} is not from an existing zwave_js config entry" + ) + if entry.state != ConfigEntryState.LOADED: + raise ValueError(f"Device {device_id} config entry is not loaded") + + client: ZwaveClient = entry.runtime_data[DATA_CLIENT] + driver = client.driver + + if driver is None: + raise ValueError("Driver is not ready.") + + provisioning_entries = await driver.controller.async_get_provisioning_entries() + for provisioning_entry in provisioning_entries: + if ( + provisioning_entry.additional_properties + and provisioning_entry.additional_properties.get("device_id") == device_id + ): + return provisioning_entry + + return None + + @callback def async_get_node_from_entity_id( hass: HomeAssistant, diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 7e8b473922f..8719c333753 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.62.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.63.0"], "usb": [ { "vid": "0658", @@ -21,6 +21,13 @@ "pid": "8A2A", "description": "*z-wave*", "known_devices": ["Nortek HUSBZB-1"] + }, + { + "vid": "303A", + "pid": "4001", + "description": "*nabu casa zwa-2*", + "manufacturer": "nabu casa", + "known_devices": ["Nabu Casa Connect ZWA-2"] } ], "zeroconf": ["_zwave-js-server._tcp.local."] diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 8f23fee4447..56ae4e12401 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -4,14 +4,24 @@ "addon_get_discovery_info_failed": "Failed to get Z-Wave add-on discovery info.", "addon_info_failed": "Failed to get Z-Wave add-on info.", "addon_install_failed": "Failed to install the Z-Wave add-on.", + "addon_required": "The Z-Wave migration flow requires the integration to be configured using the Z-Wave Supervisor add-on. You can still use the Backup and Restore buttons to migrate your network manually.", "addon_set_config_failed": "Failed to set Z-Wave configuration.", "addon_start_failed": "Failed to start the Z-Wave add-on.", + "addon_stop_failed": "Failed to stop the Z-Wave add-on.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "backup_failed": "Failed to back up network.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "config_entry_not_loaded": "The Z-Wave configuration entry is not loaded. Please try again when the configuration entry is loaded.", + "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device.", "discovery_requires_supervisor": "Discovery requires the supervisor.", + "migration_low_sdk_version": "The SDK version of the old controller is lower than {ok_sdk_version}. This means it's not possible to migrate the Non Volatile Memory (NVM) of the old controller to another controller.\n\nCheck the documentation on the manufacturer support pages of the old controller, if it's possible to upgrade the firmware of the old controller to a version that is build with SDK version {ok_sdk_version} or higher.", + "migration_successful": "Migration successful.", "not_zwave_device": "Discovered device is not a Z-Wave device.", - "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on." + "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "reset_failed": "Failed to reset controller.", + "usb_ports_failed": "Failed to get USB devices." }, "error": { "addon_start_failed": "Failed to start the Z-Wave add-on. Check the configuration.", @@ -22,11 +32,15 @@ "flow_title": "{name}", "progress": { "install_addon": "Please wait while the Z-Wave add-on installation finishes. This can take several minutes.", - "start_addon": "Please wait while the Z-Wave add-on start completes. This may take some seconds." + "start_addon": "Please wait while the Z-Wave add-on start completes. This may take some seconds.", + "backup_nvm": "Please wait while the network backup completes.", + "restore_nvm": "Please wait while the network restore completes." }, "step": { - "configure_addon": { + "configure_addon_user": { "data": { + "lr_s2_access_control_key": "Long Range S2 Access Control Key", + "lr_s2_authenticated_key": "Long Range S2 Authenticated Key", "s0_legacy_key": "S0 Key (Legacy)", "s2_access_control_key": "S2 Access Control Key", "s2_authenticated_key": "S2 Authenticated Key", @@ -36,8 +50,23 @@ "description": "The add-on will generate security keys if those fields are left empty.", "title": "Enter the Z-Wave add-on configuration" }, + "configure_addon_reconfigure": { + "data": { + "emulate_hardware": "Emulate Hardware", + "log_level": "Log level", + "lr_s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::lr_s2_access_control_key%]", + "lr_s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::lr_s2_authenticated_key%]", + "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s0_legacy_key%]", + "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_access_control_key%]", + "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_authenticated_key%]", + "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_unauthenticated_key%]", + "usb_path": "[%key:common::config_flow::data::usb_path%]" + }, + "description": "[%key:component::zwave_js::config::step::configure_addon_user::description%]", + "title": "[%key:component::zwave_js::config::step::configure_addon_user::title%]" + }, "hassio_confirm": { - "title": "Set up Z-Wave integration with the Z-Wave add-on" + "description": "Do you want to set up the Z-Wave integration with the Z-Wave add-on?" }, "install_addon": { "title": "The Z-Wave add-on installation has started" @@ -47,6 +76,11 @@ "url": "[%key:common::config_flow::data::url%]" } }, + "manual_reconfigure": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + } + }, "on_supervisor": { "data": { "use_addon": "Use the Z-Wave Supervisor add-on" @@ -54,6 +88,13 @@ "description": "Do you want to use the Z-Wave Supervisor add-on?", "title": "Select connection method" }, + "on_supervisor_reconfigure": { + "data": { + "use_addon": "[%key:component::zwave_js::config::step::on_supervisor::data::use_addon%]" + }, + "description": "[%key:component::zwave_js::config::step::on_supervisor::description%]", + "title": "[%key:component::zwave_js::config::step::on_supervisor::title%]" + }, "start_addon": { "title": "The Z-Wave add-on is starting." }, @@ -63,6 +104,33 @@ "zeroconf_confirm": { "description": "Do you want to add the Z-Wave Server with home ID {home_id} found at {url} to Home Assistant?", "title": "Discovered Z-Wave Server" + }, + "reconfigure": { + "title": "Migrate or re-configure", + "description": "Are you migrating to a new controller or re-configuring the current controller?", + "menu_options": { + "intent_migrate": "Migrate to a new controller", + "intent_reconfigure": "Re-configure the current controller" + } + }, + "intent_migrate": { + "title": "[%key:component::zwave_js::config::step::reconfigure::menu_options::intent_migrate%]", + "description": "Before setting up your new controller, your old controller needs to be reset. A backup will be performed first.\n\nDo you wish to continue?" + }, + "instruct_unplug": { + "title": "Unplug your old controller", + "description": "Backup saved to \"{file_path}\"\n\nYour old controller has been reset. If the hardware is no longer needed, you can now unplug it.\n\nPlease make sure your new controller is plugged in before continuing." + }, + "restore_failed": { + "title": "Restoring unsuccessful", + "description": "Your Z-Wave network could not be restored to the new controller. This means that your Z-Wave devices are not connected to Home Assistant.\n\nThe backup is saved to ”{file_path}”", + "submit": "Try again" + }, + "choose_serial_port": { + "data": { + "usb_path": "[%key:common::config_flow::data::usb_path%]" + }, + "title": "Select your Z-Wave device" } } }, @@ -207,60 +275,6 @@ "title": "Newer version of Z-Wave Server needed" } }, - "options": { - "abort": { - "addon_get_discovery_info_failed": "[%key:component::zwave_js::config::abort::addon_get_discovery_info_failed%]", - "addon_info_failed": "[%key:component::zwave_js::config::abort::addon_info_failed%]", - "addon_install_failed": "[%key:component::zwave_js::config::abort::addon_install_failed%]", - "addon_set_config_failed": "[%key:component::zwave_js::config::abort::addon_set_config_failed%]", - "addon_start_failed": "[%key:component::zwave_js::config::abort::addon_start_failed%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device." - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_ws_url": "[%key:component::zwave_js::config::error::invalid_ws_url%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "progress": { - "install_addon": "[%key:component::zwave_js::config::progress::install_addon%]", - "start_addon": "[%key:component::zwave_js::config::progress::start_addon%]" - }, - "step": { - "configure_addon": { - "data": { - "emulate_hardware": "Emulate Hardware", - "log_level": "Log level", - "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_addon::data::s0_legacy_key%]", - "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_access_control_key%]", - "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_authenticated_key%]", - "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_unauthenticated_key%]", - "usb_path": "[%key:common::config_flow::data::usb_path%]" - }, - "description": "[%key:component::zwave_js::config::step::configure_addon::description%]", - "title": "[%key:component::zwave_js::config::step::configure_addon::title%]" - }, - "install_addon": { - "title": "[%key:component::zwave_js::config::step::install_addon::title%]" - }, - "manual": { - "data": { - "url": "[%key:common::config_flow::data::url%]" - } - }, - "on_supervisor": { - "data": { - "use_addon": "[%key:component::zwave_js::config::step::on_supervisor::data::use_addon%]" - }, - "description": "[%key:component::zwave_js::config::step::on_supervisor::description%]", - "title": "[%key:component::zwave_js::config::step::on_supervisor::title%]" - }, - "start_addon": { - "title": "[%key:component::zwave_js::config::step::start_addon::title%]" - } - } - }, "services": { "bulk_set_partial_config_parameters": { "description": "Allows for bulk setting partial parameters. Useful when multiple partial parameters have to be set at the same time.", diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json index d5c5a69cb96..e687f992afc 100644 --- a/homeassistant/components/zwave_me/manifest.json +++ b/homeassistant/components/zwave_me/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_me", "iot_class": "local_push", - "requirements": ["zwave-me-ws==0.4.3", "url-normalize==1.4.3"], + "requirements": ["zwave-me-ws==0.4.3", "url-normalize==2.2.1"], "zeroconf": [ { "type": "_hap._tcp.local.", diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 8c7ae5fe356..c58a33ad68d 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -30,7 +30,6 @@ from propcache.api import cached_property import voluptuous as vol from . import data_entry_flow, loader -from .components import persistent_notification from .const import ( CONF_NAME, EVENT_HOMEASSISTANT_STARTED, @@ -73,12 +72,12 @@ from .helpers.json import json_bytes, json_bytes_sorted, json_fragment from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType from .loader import async_suggest_report_issue from .setup import ( - DATA_SETUP_DONE, SetupPhases, async_pause_setup, async_process_deps_reqs, async_setup_component, async_start_setup, + async_wait_component, ) from .util import ulid as ulid_util from .util.async_ import create_eager_task @@ -178,7 +177,6 @@ class ConfigEntryState(Enum): DEFAULT_DISCOVERY_UNIQUE_ID = "default_discovery_unique_id" -DISCOVERY_NOTIFICATION_ID = "config_entry_discovery" DISCOVERY_SOURCES = { SOURCE_BLUETOOTH, SOURCE_DHCP, @@ -1369,14 +1367,15 @@ class ConfigEntriesFlowManager( self._initialize_futures: defaultdict[str, set[asyncio.Future[None]]] = ( defaultdict(set) ) - self._discovery_debouncer = Debouncer[None]( + self._discovery_event_debouncer = Debouncer[None]( hass, _LOGGER, cooldown=DISCOVERY_COOLDOWN, immediate=True, - function=self._async_discovery, + function=self._async_fire_discovery_event, background=True, ) + self._flow_subscriptions: list[Callable[[str, str], None]] = [] async def async_wait_import_flow_initialized(self, handler: str) -> None: """Wait till all import flows in progress are initialized.""" @@ -1385,14 +1384,6 @@ class ConfigEntriesFlowManager( await asyncio.wait(current.values()) - @callback - def _async_has_other_discovery_flows(self, flow_id: str) -> bool: - """Check if there are any other discovery flows in progress.""" - for flow in self._progress.values(): - if flow.flow_id != flow_id and flow.context["source"] in DISCOVERY_SOURCES: - return True - return False - async def async_init( self, handler: str, @@ -1464,8 +1455,19 @@ class ConfigEntriesFlowManager( if not self._pending_import_flows[handler]: del self._pending_import_flows[handler] - if result["type"] != data_entry_flow.FlowResultType.ABORT: - await self.async_post_init(flow, result) + if ( + result["type"] != data_entry_flow.FlowResultType.ABORT + and source in DISCOVERY_SOURCES + ): + # Fire discovery event + await self._discovery_event_debouncer.async_call() + + if result["type"] != data_entry_flow.FlowResultType.ABORT and source in ( + DISCOVERY_SOURCES | {SOURCE_REAUTH} + ): + # Notify listeners that a flow is created + for subscription in self._flow_subscriptions: + subscription("added", flow.flow_id) return result @@ -1507,7 +1509,22 @@ class ConfigEntriesFlowManager( for future_list in self._initialize_futures.values(): for future in future_list: future.set_result(None) - self._discovery_debouncer.async_shutdown() + self._discovery_event_debouncer.async_shutdown() + + @callback + def async_flow_removed( + self, + flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult], + ) -> None: + """Handle a removed config flow.""" + flow = cast(ConfigFlow, flow) + + # Clean up issue if this is a reauth flow + if flow.context["source"] == SOURCE_REAUTH: + if (entry_id := flow.context.get("entry_id")) is not None: + # The config entry's domain is flow.handler + issue_id = f"config_entry_reauth_{flow.handler}_{entry_id}" + ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) async def async_finish_flow( self, @@ -1521,26 +1538,8 @@ class ConfigEntriesFlowManager( """ flow = cast(ConfigFlow, flow) - # Mark the step as done. - # We do this to avoid a circular dependency where async_finish_flow sets up a - # new entry, which needs the integration to be set up, which is waiting for - # init to be done. - self._set_pending_import_done(flow) - - # Remove notification if no other discovery config entries in progress - if not self._async_has_other_discovery_flows(flow.flow_id): - persistent_notification.async_dismiss(self.hass, DISCOVERY_NOTIFICATION_ID) - - # Clean up issue if this is a reauth flow - if flow.context["source"] == SOURCE_REAUTH: - if (entry_id := flow.context.get("entry_id")) is not None and ( - entry := self.config_entries.async_get_entry(entry_id) - ) is not None: - issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}" - ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) - if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: - # If there's an ignored config entry with a matching unique ID, + # If there's a config entry with a matching unique ID, # update the discovery key. if ( (discovery_key := flow.context.get("discovery_key")) @@ -1577,6 +1576,12 @@ class ConfigEntriesFlowManager( ) return result + # Mark the step as done. + # We do this to avoid a circular dependency where async_finish_flow sets up a + # new entry, which needs the integration to be set up, which is waiting for + # init to be done. + self._set_pending_import_done(flow) + # Avoid adding a config entry for a integration # that only supports a single config entry, but already has an entry if ( @@ -1705,33 +1710,12 @@ class ConfigEntriesFlowManager( flow.init_step = context["source"] return flow - async def async_post_init( - self, - flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult], - result: ConfigFlowResult, - ) -> None: - """After a flow is initialised trigger new flow notifications.""" - source = flow.context["source"] - - # Create notification. - if source in DISCOVERY_SOURCES: - await self._discovery_debouncer.async_call() - @callback - def _async_discovery(self) -> None: - """Handle discovery.""" + def _async_fire_discovery_event(self) -> None: + """Fire discovery event.""" # async_fire_internal is used here because this is only # called from the Debouncer so we know the usage is safe self.hass.bus.async_fire_internal(EVENT_FLOW_DISCOVERED) - persistent_notification.async_create( - self.hass, - title="New devices discovered", - message=( - "We have discovered new devices on your network. " - "[Check it out](/config/integrations)." - ), - notification_id=DISCOVERY_NOTIFICATION_ID, - ) @callback def async_has_matching_discovery_flow( @@ -1762,6 +1746,29 @@ class ConfigEntriesFlowManager( return True return False + @callback + def async_subscribe_flow( + self, listener: Callable[[str, str], None] + ) -> CALLBACK_TYPE: + """Subscribe to non user initiated flow init or remove.""" + self._flow_subscriptions.append(listener) + return lambda: self._flow_subscriptions.remove(listener) + + @callback + def _async_remove_flow_progress(self, flow_id: str) -> None: + """Remove a flow from in progress.""" + flow = self._progress.get(flow_id) + super()._async_remove_flow_progress(flow_id) + # Fire remove event for initialized non user initiated flows + if ( + not flow + or flow.cur_step is None + or flow.source not in (DISCOVERY_SOURCES | {SOURCE_REAUTH}) + ): + return + for listeners in self._flow_subscriptions: + listeners("removed", flow_id) + class ConfigEntryItems(UserDict[str, ConfigEntry]): """Container for config items, maps config_entry_id -> entry. @@ -2120,13 +2127,7 @@ class ConfigEntries: # If the configuration entry is removed during reauth, it should # abort any reauth flow that is active for the removed entry and # linked issues. - for progress_flow in self.hass.config_entries.flow.async_progress_by_handler( - entry.domain, match_context={"entry_id": entry_id, "source": SOURCE_REAUTH} - ): - if "flow_id" in progress_flow: - self.hass.config_entries.flow.async_abort(progress_flow["flow_id"]) - issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}" - ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) + _abort_reauth_flows(self.hass, entry.domain, entry_id) self._async_dispatch(ConfigEntryChange.REMOVED, entry) @@ -2258,6 +2259,9 @@ class ConfigEntries: # attempts. entry.async_cancel_retry_setup() + # Abort any in-progress reauth flow and linked issues + _abort_reauth_flows(self.hass, entry.domain, entry_id) + if entry.domain not in self.hass.config.components: # If the component is not loaded, just load it as # the config entry will be loaded as well. We need @@ -2380,12 +2384,7 @@ class ConfigEntries: if unique_id is not UNDEFINED and entry.unique_id != unique_id: # Deprecated in 2024.11, should fail in 2025.11 if ( - # flipr creates duplicates during migration, and asks users to - # remove the duplicate. We don't need warn about it here too. - # We should remove the special case for "flipr" in HA Core 2025.4, - # when the flipr migration period ends - entry.domain != "flipr" - and unique_id is not None + unique_id is not None and self.async_entry_for_domain_unique_id(entry.domain, unique_id) is not None ): @@ -2732,11 +2731,7 @@ class ConfigEntries: Config entries which are created after Home Assistant is started can't be waited for, the function will just return if the config entry is loaded or not. """ - setup_done = self.hass.data.get(DATA_SETUP_DONE, {}) - if setup_future := setup_done.get(entry.domain): - await setup_future - # The component was not loaded. - if entry.domain not in self.hass.config.components: + if not await async_wait_component(self.hass, entry.domain): return False return entry.state is ConfigEntryState.LOADED @@ -2756,12 +2751,6 @@ class ConfigEntries: issues.add(issue.issue_id) for domain, unique_ids in self._entries._domain_unique_id_index.items(): # noqa: SLF001 - # flipr creates duplicates during migration, and asks users to - # remove the duplicate. We don't need warn about it here too. - # We should remove the special case for "flipr" in HA Core 2025.4, - # when the flipr migration period ends - if domain == "flipr": - continue for unique_id, entries in unique_ids.items(): # We might mutate the list of entries, so we need a copy to not mess up # the index @@ -2924,6 +2913,7 @@ class ConfigFlow(ConfigEntryBaseFlow): reload_on_update: bool = True, *, error: str = "already_configured", + description_placeholders: Mapping[str, str] | None = None, ) -> None: """Abort if the unique ID is already configured. @@ -2964,7 +2954,7 @@ class ConfigFlow(ConfigEntryBaseFlow): return if should_reload: self.hass.config_entries.async_schedule_reload(entry.entry_id) - raise data_entry_flow.AbortFlow(error) + raise data_entry_flow.AbortFlow(error, description_placeholders) async def async_set_unique_id( self, unique_id: str | None = None, *, raise_on_progress: bool = True @@ -3793,3 +3783,13 @@ async def _async_get_flow_handler( return handler raise data_entry_flow.UnknownHandler + + +@callback +def _abort_reauth_flows(hass: HomeAssistant, domain: str, entry_id: str) -> None: + """Abort reauth flows for an entry.""" + for progress_flow in hass.config_entries.flow.async_progress_by_handler( + domain, match_context={"entry_id": entry_id, "source": SOURCE_REAUTH} + ): + if "flow_id" in progress_flow: + hass.config_entries.flow.async_abort(progress_flow["flow_id"]) diff --git a/homeassistant/const.py b/homeassistant/const.py index 25c5eaf019b..11abbd33b41 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,12 +24,12 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "4" +MINOR_VERSION: Final = 5 +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" -REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) -REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) +REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) +REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) # Truthy date string triggers showing related deprecation warning messages. REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" @@ -115,6 +115,7 @@ SUN_EVENT_SUNRISE: Final = "sunrise" CONF_ABOVE: Final = "above" CONF_ACCESS_TOKEN: Final = "access_token" CONF_ACTION: Final = "action" +CONF_ACTIONS: Final = "actions" CONF_ADDRESS: Final = "address" CONF_AFTER: Final = "after" CONF_ALIAS: Final = "alias" @@ -603,6 +604,7 @@ class UnitOfReactivePower(StrEnum): """Reactive power units.""" VOLT_AMPERE_REACTIVE = "var" + KILO_VOLT_AMPERE_REACTIVE = "kvar" _DEPRECATED_POWER_VOLT_AMPERE_REACTIVE: Final = DeprecatedConstantEnum( @@ -765,8 +767,11 @@ class UnitOfVolumeFlowRate(StrEnum): """Volume flow rate units.""" CUBIC_METERS_PER_HOUR = "m³/h" + CUBIC_METERS_PER_SECOND = "m³/s" CUBIC_FEET_PER_MINUTE = "ft³/min" + LITERS_PER_HOUR = "L/h" LITERS_PER_MINUTE = "L/min" + LITERS_PER_SECOND = "L/s" GALLONS_PER_MINUTE = "gal/min" MILLILITERS_PER_SECOND = "mL/s" diff --git a/homeassistant/core.py b/homeassistant/core.py index 46ae499e2ca..d7535907dfc 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -38,6 +38,7 @@ from typing import ( TypedDict, TypeVar, cast, + final, overload, ) @@ -71,6 +72,7 @@ from .const import ( MAX_EXPECTED_ENTITY_IDS, MAX_LENGTH_EVENT_EVENT_TYPE, MAX_LENGTH_STATE_STATE, + STATE_UNKNOWN, __version__, ) from .exceptions import ( @@ -324,6 +326,7 @@ class HassJobType(enum.Enum): Executor = 3 +@final # Final to allow direct checking of the type instead of using isinstance class HassJob[**_P, _R_co]: """Represent a job to be run later. @@ -425,9 +428,6 @@ class HomeAssistant: def __init__(self, config_dir: str) -> None: """Initialize new Home Assistant object.""" - # pylint: disable-next=import-outside-toplevel - from . import loader - # pylint: disable-next=import-outside-toplevel from .core_config import Config @@ -441,8 +441,6 @@ class HomeAssistant: self.states = StateMachine(self.bus, self.loop) self.config = Config(self, config_dir) self.config.async_initialize() - self.components = loader.Components(self) - self.helpers = loader.Helpers(self) self.state: CoreState = CoreState.not_running self.exit_code: int = 0 # If not None, use to signal end-of-loop @@ -1317,6 +1315,7 @@ class EventOrigin(enum.Enum): return next((idx for idx, origin in enumerate(EventOrigin) if origin is self)) +@final # Final to allow direct checking of the type instead of using isinstance class Event(Generic[_DataT]): """Representation of an event within the bus.""" @@ -1796,18 +1795,13 @@ class State: ) -> None: """Initialize a new state.""" self._cache: dict[str, Any] = {} - state = str(state) - if validate_entity_id and not valid_entity_id(entity_id): raise InvalidEntityFormatError( f"Invalid entity id encountered: {entity_id}. " "Format should be ." ) - - validate_state(state) - self.entity_id = entity_id - self.state = state + self.state = state if type(state) is str else str(state) # State only creates and expects a ReadOnlyDict so # there is no need to check for subclassing with # isinstance here so we can use the faster type check. @@ -1935,13 +1929,14 @@ class State: # to avoid callers outside of this module # from misusing it by mistake. context = state_context._as_dict # noqa: SLF001 + last_changed_timestamp = self.last_changed_timestamp compressed_state: CompressedState = { COMPRESSED_STATE_STATE: self.state, COMPRESSED_STATE_ATTRIBUTES: self.attributes, COMPRESSED_STATE_CONTEXT: context, - COMPRESSED_STATE_LAST_CHANGED: self.last_changed_timestamp, + COMPRESSED_STATE_LAST_CHANGED: last_changed_timestamp, } - if self.last_changed != self.last_updated: + if last_changed_timestamp != self.last_updated_timestamp: compressed_state[COMPRESSED_STATE_LAST_UPDATED] = ( self.last_updated_timestamp ) @@ -2235,7 +2230,6 @@ class StateMachine: This avoids a race condition where multiple entities with the same entity_id are added. """ - entity_id = entity_id.lower() if entity_id in self._states_data or entity_id in self._reservations: raise HomeAssistantError( "async_reserve must not be called once the state is in the state" @@ -2272,9 +2266,11 @@ class StateMachine: This method must be run in the event loop. """ + state = str(new_state) + validate_state(state) self.async_set_internal( entity_id.lower(), - str(new_state), + state, attributes or {}, force_update, context, @@ -2300,6 +2296,8 @@ class StateMachine: breaking changes to this function in the future and it should not be used in integrations. + Callers are responsible for ensuring the entity_id is lower case. + This method must be run in the event loop. """ # Most cases the key will be in the dict @@ -2358,6 +2356,16 @@ class StateMachine: assert old_state is not None attributes = old_state.attributes + if not same_state and len(new_state) > MAX_LENGTH_STATE_STATE: + _LOGGER.error( + "State %s for %s is longer than %s, falling back to %s", + new_state, + entity_id, + MAX_LENGTH_STATE_STATE, + STATE_UNKNOWN, + ) + new_state = STATE_UNKNOWN + # This is intentionally called with positional only arguments for performance # reasons state = State( diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index f080705fced..9cd232097a7 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -581,9 +581,7 @@ class Config: self.all_components: set[str] = set() # Set of loaded components - self.components: _ComponentSet = _ComponentSet( - self.top_level_components, self.all_components - ) + self.components = _ComponentSet(self.top_level_components, self.all_components) # API (HTTP) server configuration self.api: ApiConfig | None = None diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index f7be891b61b..9286f9c78f5 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -40,6 +40,7 @@ class FlowResultType(StrEnum): # Event that is fired when a flow is progressed via external or progress source. EVENT_DATA_ENTRY_FLOW_PROGRESSED = "data_entry_flow_progressed" +EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE = "data_entry_flow_progress_update" FLOW_NOT_COMPLETE_STEPS = { FlowResultType.FORM, @@ -207,6 +208,13 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): Handler key is the domain of the component that we want to set up. """ + @callback + def async_flow_removed( + self, + flow: FlowHandler[_FlowContextT, _FlowResultT, _HandlerT], + ) -> None: + """Handle a removed data entry flow.""" + @abc.abstractmethod async def async_finish_flow( self, @@ -219,13 +227,6 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): FlowResultType.CREATE_ENTRY. """ - async def async_post_init( - self, - flow: FlowHandler[_FlowContextT, _FlowResultT, _HandlerT], - result: _FlowResultT, - ) -> None: - """Entry has finished executing its first step asynchronously.""" - @callback def async_get(self, flow_id: str) -> _FlowResultT: """Return a flow in progress as a partial FlowResult.""" @@ -312,12 +313,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): flow.init_data = data self._async_add_flow_progress(flow) - result = await self._async_handle_step(flow, flow.init_step, data) - - if result["type"] != FlowResultType.ABORT: - await self.async_post_init(flow, result) - - return result + return await self._async_handle_step(flow, flow.init_step, data) async def async_configure( self, flow_id: str, user_input: dict | None = None @@ -469,6 +465,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): """Remove a flow from in progress.""" if (flow := self._progress.pop(flow_id, None)) is None: raise UnknownFlow + self.async_flow_removed(flow) self._async_remove_flow_from_index(flow) flow.async_cancel_progress_task() try: @@ -497,6 +494,13 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): description_placeholders=err.description_placeholders, ) + if flow.flow_id not in self._progress: + # The flow was removed during the step, raise UnknownFlow + # unless the result is an abort + if result["type"] != FlowResultType.ABORT: + raise UnknownFlow + return result + # Setup the flow handler's preview if needed if result.get("preview") is not None: await self._async_setup_preview(flow) @@ -547,7 +551,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): flow.cur_step = result return result - # Abort and Success results both finish the flow + # Abort and Success results both finish the flow. self._async_remove_flow_progress(flow.flow_id) return result @@ -826,6 +830,14 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): flow_result["step_id"] = step_id return flow_result + @callback + def async_update_progress(self, progress: float) -> None: + """Update the progress of a flow. `progress` must be between 0 and 1.""" + self.hass.bus.async_fire_internal( + EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE, + {"handler": self.handler, "flow_id": self.flow_id, "progress": progress}, + ) + @callback def async_show_progress_done(self, *, next_step_id: str) -> _FlowResultT: """Mark the progress done.""" diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 68c6de405e6..2f088716f8c 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -19,7 +19,9 @@ APPLICATION_CREDENTIALS = [ "iotty", "lametric", "lyric", + "mcp", "microbees", + "miele", "monzo", "myuplink", "neato", diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 1ff444ca25f..e796625f81c 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -202,6 +202,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "govee_ble", "local_name": "GVH5130*", }, + { + "connectable": False, + "domain": "govee_ble", + "local_name": "GVH5110*", + }, { "connectable": False, "domain": "govee_ble", @@ -371,6 +376,26 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "inkbird", "local_name": "ITH-21-B", }, + { + "connectable": False, + "domain": "inkbird", + "local_name": "IBS-P02B", + }, + { + "connectable": True, + "domain": "inkbird", + "local_name": "Ink@IAM-T1", + }, + { + "connectable": True, + "domain": "inkbird", + "manufacturer_data_start": [ + 65, + 67, + 45, + ], + "manufacturer_id": 12628, + }, { "connectable": True, "domain": "iron_os", @@ -389,6 +414,10 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "keymitt_ble", "local_name": "mib*", }, + { + "domain": "kulersky", + "service_uuid": "8d96a001-0002-64c2-0001-9acc4838521c", + }, { "domain": "lamarzocco", "local_name": "MICRA_*", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d192b8fcd13..d3fae81d287 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -75,6 +75,7 @@ FLOWS = { "aussie_broadband", "autarco", "awair", + "aws_s3", "axis", "azure_data_explorer", "azure_devops", @@ -285,6 +286,7 @@ FLOWS = { "ifttt", "igloohome", "imap", + "imeon_inverter", "imgw_pib", "improv_ble", "incomfort", @@ -377,6 +379,7 @@ FLOWS = { "meteoclimatic", "metoffice", "microbees", + "miele", "mikrotik", "mill", "minecraft_server", @@ -426,6 +429,7 @@ FLOWS = { "nobo_hub", "nordpool", "notion", + "ntfy", "nuheat", "nuki", "nut", @@ -437,7 +441,6 @@ FLOWS = { "ohme", "ollama", "omnilogic", - "oncue", "ondilo_ico", "onedrive", "onewire", @@ -515,6 +518,7 @@ FLOWS = { "rdw", "recollect_waste", "refoss", + "rehlko", "remote_calendar", "renault", "renson", @@ -599,6 +603,7 @@ FLOWS = { "starlink", "steam_online", "steamist", + "stiebel_eltron", "stookwijzer", "streamlabswater", "subaru", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 8ee1ea270f3..53506ed1748 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -84,6 +84,16 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "blink*", "macaddress": "20A171*", }, + { + "domain": "bond", + "hostname": "bond-*", + "macaddress": "3C6A2C1*", + }, + { + "domain": "bond", + "hostname": "bond-*", + "macaddress": "F44E38*", + }, { "domain": "broadlink", "registered_devices": True, @@ -394,11 +404,6 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "obihai", "macaddress": "9CADEF*", }, - { - "domain": "oncue", - "hostname": "kohlergen*", - "macaddress": "00146F*", - }, { "domain": "onvif", "registered_devices": True, @@ -461,6 +466,11 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "rainforest_eagle", "macaddress": "D8D5B9*", }, + { + "domain": "rehlko", + "hostname": "kohlergen*", + "macaddress": "00146F*", + }, { "domain": "reolink", "hostname": "reolink*", @@ -603,6 +613,15 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "sleepiq", "macaddress": "64DBA0*", }, + { + "domain": "sma", + "hostname": "sma*", + "macaddress": "0015BB*", + }, + { + "domain": "sma", + "registered_devices": True, + }, { "domain": "smartthings", "hostname": "st*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index da4f08f157d..d05944ce628 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -219,6 +219,12 @@ "iot_class": "cloud_push", "name": "Amazon Web Services (AWS)" }, + "aws_s3": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_push", + "name": "AWS S3" + }, "fire_tv": { "integration_type": "virtual", "config_flow": false, @@ -630,6 +636,11 @@ "config_flow": false, "iot_class": "cloud_push" }, + "balay": { + "name": "Balay", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "balboa": { "name": "Balboa Spa Client", "integration_type": "hub", @@ -1066,6 +1077,11 @@ "integration_type": "virtual", "supported_by": "opower" }, + "constructa": { + "name": "Constructa", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "control4": { "name": "Control4", "integration_type": "hub", @@ -2180,6 +2196,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "gaggenau": { + "name": "Gaggenau", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "garadget": { "name": "Garadget", "integration_type": "hub", @@ -2346,6 +2367,12 @@ "iot_class": "cloud_polling", "name": "Google Drive" }, + "google_gemini": { + "integration_type": "virtual", + "config_flow": false, + "supported_by": "google_generative_ai_conversation", + "name": "Google Gemini" + }, "google_generative_ai_conversation": { "integration_type": "service", "config_flow": true, @@ -2920,6 +2947,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "imeon_inverter": { + "name": "Imeon Inverter", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "imgw_pib": { "name": "IMGW-PIB", "integration_type": "hub", @@ -3308,7 +3341,7 @@ "name": "La Marzocco", "integration_type": "device", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "cloud_push" }, "lametric": { "name": "LaMetric", @@ -3701,6 +3734,11 @@ "config_flow": true, "iot_class": "local_push" }, + "maytag": { + "name": "Maytag", + "integration_type": "virtual", + "supported_by": "whirlpool" + }, "mcp": { "name": "Model Context Protocol", "integration_type": "hub", @@ -3916,6 +3954,13 @@ } } }, + "miele": { + "name": "Miele", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push", + "single_config_entry": true + }, "mijndomein_energie": { "name": "Mijndomein Energie", "integration_type": "virtual", @@ -4195,6 +4240,11 @@ "config_flow": true, "iot_class": "local_push" }, + "national_grid_us": { + "name": "National Grid US", + "integration_type": "virtual", + "supported_by": "opower" + }, "neato": { "name": "Neato Botvac", "integration_type": "hub", @@ -4207,6 +4257,11 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "neff": { + "name": "Neff", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "ness_alarm": { "name": "Ness Alarm", "integration_type": "hub", @@ -4397,6 +4452,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "ntfy": { + "name": "ntfy", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "nuheat": { "name": "NuHeat", "integration_type": "hub", @@ -4405,9 +4466,17 @@ }, "nuki": { "name": "Nuki", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" + "integrations": { + "nuki": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Nuki Bridge" + } + }, + "iot_standards": [ + "matter" + ] }, "numato": { "name": "Numato USB GPIO Expander", @@ -4511,12 +4580,6 @@ "iot_class": "cloud_polling", "single_config_entry": true }, - "oncue": { - "name": "Oncue by Kohler", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "ondilo_ico": { "name": "Ondilo ICO", "integration_type": "hub", @@ -4900,6 +4963,11 @@ "integration_type": "virtual", "supported_by": "wyoming" }, + "pitsos": { + "name": "Pitsos", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "pjlink": { "name": "PJLink", "integration_type": "hub", @@ -4975,6 +5043,11 @@ "config_flow": true, "single_config_entry": true }, + "profilo": { + "name": "Profilo", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "progettihwsw": { "name": "ProgettiHWSW Automation", "integration_type": "hub", @@ -5313,6 +5386,12 @@ "iot_class": "local_polling", "single_config_entry": true }, + "rehlko": { + "name": "Rehlko", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "rejseplanen": { "name": "Rejseplanen", "integration_type": "hub", @@ -5787,6 +5866,11 @@ "config_flow": true, "iot_class": "local_push" }, + "siemens": { + "name": "Siemens", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "sigfox": { "name": "Sigfox", "integration_type": "hub", @@ -6204,7 +6288,7 @@ "stiebel_eltron": { "name": "STIEBEL ELTRON", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "stookwijzer": { @@ -6539,6 +6623,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "thermador": { + "name": "Thermador", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "thermobeacon": { "name": "ThermoBeacon", "integration_type": "hub", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 5bbc178ba17..acbb74645a3 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -166,6 +166,13 @@ SSDP = { "st": "urn:hyperion-project.org:device:basic:1", }, ], + "imeon_inverter": [ + { + "deviceType": "urn:schemas-upnp-org:device:Basic:1", + "manufacturer": "IMEON", + "st": "upnp:rootdevice", + }, + ], "isy994": [ { "deviceType": "urn:udi-com:device:X_Insteon_Lighting_Device:1", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index e66a5861d18..8aea15df283 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -148,4 +148,11 @@ USB = [ "pid": "8A2A", "vid": "10C4", }, + { + "description": "*nabu casa zwa-2*", + "domain": "zwave_js", + "manufacturer": "nabu casa", + "pid": "4001", + "vid": "303A", + }, ] diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index cc1683a3603..38f90663601 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -525,6 +525,11 @@ ZEROCONF = { "domain": "homekit_controller", }, ], + "_homeconnect._tcp.local.": [ + { + "domain": "home_connect", + }, + ], "_homekit._tcp.local.": [ { "domain": "homekit", @@ -710,6 +715,11 @@ ZEROCONF = { "domain": "thread", }, ], + "_mieleathome._tcp.local.": [ + { + "domain": "miele", + }, + ], "_miio._udp.local.": [ { "domain": "xiaomi_aqara", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 3d8dc247857..a9976cf7e32 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -28,6 +28,7 @@ from homeassistant.util.json import json_loads from .frame import warn_use from .json import json_dumps +from .singleton import singleton if TYPE_CHECKING: from aiohttp.typedefs import JSONDecoder @@ -39,6 +40,7 @@ DATA_CONNECTOR: HassKey[dict[tuple[bool, int, str], aiohttp.BaseConnector]] = Ha DATA_CLIENTSESSION: HassKey[dict[tuple[bool, int, str], aiohttp.ClientSession]] = ( HassKey("aiohttp_clientsession") ) +DATA_RESOLVER: HassKey[HassAsyncDNSResolver] = HassKey("aiohttp_resolver") SERVER_SOFTWARE = ( f"{APPLICATION_NAME}/{__version__} " @@ -70,6 +72,21 @@ MAXIMUM_CONNECTIONS = 4096 MAXIMUM_CONNECTIONS_PER_HOST = 100 +class HassAsyncDNSResolver(AsyncDualMDNSResolver): + """Home Assistant AsyncDNSResolver. + + This is a wrapper around the AsyncDualMDNSResolver to only + close the resolver when the Home Assistant instance is closed. + """ + + async def real_close(self) -> None: + """Close the resolver.""" + await super().close() + + async def close(self) -> None: + """Close the resolver.""" + + class HassClientResponse(aiohttp.ClientResponse): """aiohttp.ClientResponse with a json method that uses json_loads by default.""" @@ -363,7 +380,7 @@ def _async_get_connector( ssl=ssl_context, limit=MAXIMUM_CONNECTIONS, limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST, - resolver=_async_make_resolver(hass), + resolver=_async_get_or_create_resolver(hass), ) connectors[connector_key] = connector @@ -376,6 +393,19 @@ def _async_get_connector( return connector +@singleton(DATA_RESOLVER) @callback -def _async_make_resolver(hass: HomeAssistant) -> AsyncDualMDNSResolver: - return AsyncDualMDNSResolver(async_zeroconf=zeroconf.async_get_async_zeroconf(hass)) +def _async_get_or_create_resolver(hass: HomeAssistant) -> HassAsyncDNSResolver: + """Return the HassAsyncDNSResolver.""" + resolver = _async_make_resolver(hass) + + async def _async_close_resolver(event: Event) -> None: + await resolver.real_close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_resolver) + return resolver + + +@callback +def _async_make_resolver(hass: HomeAssistant) -> HassAsyncDNSResolver: + return HassAsyncDNSResolver(async_zeroconf=zeroconf.async_get_async_zeroconf(hass)) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 5601ce4032d..ba02ed51f6b 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -20,6 +20,7 @@ from .json import json_bytes, json_fragment from .normalized_name_base_registry import ( NormalizedNameBaseRegistryEntry, NormalizedNameBaseRegistryItems, + normalize_name, ) from .registry import BaseRegistry, RegistryIndexType from .singleton import singleton @@ -169,6 +170,7 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): super().__init__() self._labels_index: RegistryIndexType = defaultdict(dict) self._floors_index: RegistryIndexType = defaultdict(dict) + self._aliases_index: RegistryIndexType = defaultdict(dict) def _index_entry(self, key: str, entry: AreaEntry) -> None: """Index an entry.""" @@ -177,6 +179,9 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): self._floors_index[entry.floor_id][key] = True for label in entry.labels: self._labels_index[label][key] = True + for alias in entry.aliases: + normalized_alias = normalize_name(alias) + self._aliases_index[normalized_alias][key] = True def _unindex_entry( self, key: str, replacement_entry: AreaEntry | None = None @@ -184,6 +189,10 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): # always call base class before other indices super()._unindex_entry(key, replacement_entry) entry = self.data[key] + if aliases := entry.aliases: + for alias in aliases: + normalized_alias = normalize_name(alias) + self._unindex_entry_value(key, normalized_alias, self._aliases_index) if labels := entry.labels: for label in labels: self._unindex_entry_value(key, label, self._labels_index) @@ -200,6 +209,12 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): data = self.data return [data[key] for key in self._floors_index.get(floor, ())] + def get_areas_for_alias(self, alias: str) -> list[AreaEntry]: + """Get areas for alias.""" + data = self.data + normalized_alias = normalize_name(alias) + return [data[key] for key in self._aliases_index.get(normalized_alias, ())] + class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): """Class to hold a registry of areas.""" @@ -232,6 +247,11 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): """Get area by name.""" return self.areas.get_by_name(name) + @callback + def async_get_areas_by_alias(self, alias: str) -> list[AreaEntry]: + """Get areas by alias.""" + return self.areas.get_areas_for_alias(alias) + @callback def async_list_areas(self) -> Iterable[AreaEntry]: """Get all areas.""" diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 84728978ede..1cff90031c2 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -27,11 +27,11 @@ import voluptuous as vol from yarl import URL from homeassistant import config_entries -from homeassistant.components import http from homeassistant.core import HomeAssistant, callback from homeassistant.loader import async_get_application_credentials from homeassistant.util.hass_dict import HassKey +from . import http from .aiohttp_client import async_get_clientsession from .network import NoURLAvailableError diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 5c1a7c99565..0ce2c9e02e0 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -21,7 +21,7 @@ from socket import ( # type: ignore[attr-defined] # private, not in typeshed _GLOBAL_DEFAULT_TIMEOUT, ) import threading -from typing import Any, cast, overload +from typing import TYPE_CHECKING, Any, cast, overload from urllib.parse import urlparse from uuid import UUID @@ -355,7 +355,13 @@ 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] + if isinstance(value, list): + if TYPE_CHECKING: + # https://github.com/home-assistant/core/pull/71960 + # cast with a type variable is still slow. + return cast(list[_T], value) + return value # type: ignore[unreachable] + return [value] def entity_id(value: Any) -> str: @@ -1053,6 +1059,31 @@ def removed( ) +def renamed( + old_key: str, + new_key: str, +) -> Callable[[Any], Any]: + """Replace key with a new key. + + Fails if both the new and old key are present. + """ + + def validator(value: Any) -> Any: + if not isinstance(value, dict): + return value + + if old_key in value: + if new_key in value: + raise vol.Invalid( + f"Cannot specify both '{old_key}' and '{new_key}'. Please use '{new_key}' only." + ) + value[new_key] = value.pop(old_key) + + return value + + return validator + + def key_value_schemas( key: str, value_schemas: dict[Hashable, VolSchemaType | Callable[[Any], dict[str, Any]]], diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 991a6cf5a57..79d6774c407 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -581,8 +581,8 @@ class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)]( def get_entry( self, - identifiers: set[tuple[str, str]] | None, - connections: set[tuple[str, str]] | None, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, ) -> _EntryTypeT | None: """Get entry from identifiers or connections.""" if identifiers: @@ -709,22 +709,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): """Check if device is registered.""" return self.devices.get_entry(identifiers, connections) - def _async_get_deleted_device( - self, - identifiers: set[tuple[str, str]], - connections: set[tuple[str, str]], - ) -> DeletedDeviceEntry | None: - """Check if device is deleted.""" - return self.deleted_devices.get_entry(identifiers, connections) - - def _async_get_deleted_devices( - self, - identifiers: set[tuple[str, str]] | None = None, - connections: set[tuple[str, str]] | None = None, - ) -> Iterable[DeletedDeviceEntry]: - """List devices that are deleted.""" - return self.deleted_devices.get_entries(identifiers, connections) - def _substitute_name_placeholders( self, domain: str, @@ -839,10 +823,12 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): else: connections = _normalize_connections(connections) - device = self.async_get_device(identifiers=identifiers, connections=connections) + device = self.devices.get_entry( + identifiers=identifiers, connections=connections + ) if device is None: - deleted_device = self._async_get_deleted_device(identifiers, connections) + deleted_device = self.deleted_devices.get_entry(identifiers, connections) if deleted_device is None: device = DeviceEntry(is_new=True) else: @@ -869,7 +855,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): name = default_name if via_device is not None and via_device is not UNDEFINED: - if (via := self.async_get_device(identifiers={via_device})) is None: + if (via := self.devices.get_entry(identifiers={via_device})) is None: report_usage( "calls `device_registry.async_get_or_create` referencing a " f"non existing `via_device` {via_device}, " @@ -1172,7 +1158,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # NOTE: Once we solve the broader issue of duplicated devices, we might # want to revisit it. Instead of simply removing the duplicated deleted device, # we might want to merge the information from it into the non-deleted device. - for deleted_device in self._async_get_deleted_devices( + for deleted_device in self.deleted_devices.get_entries( added_identifiers, added_connections ): del self.deleted_devices[deleted_device.id] @@ -1214,7 +1200,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.async_get_device(connections={connection}) + existing_device := self.devices.get_entry(connections={connection}) ) and existing_device.id != device_id: raise DeviceConnectionCollisionError( normalized_connections, existing_device @@ -1238,7 +1224,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.async_get_device(identifiers={identifier}) + existing_device := self.devices.get_entry(identifiers={identifier}) ) and existing_device.id != device_id: raise DeviceIdentifierCollisionError(identifiers, existing_device) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index bdcda58c054..a3edf6bb64f 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -49,11 +49,7 @@ from homeassistant.core import ( get_release_channel, ) from homeassistant.core_config import DATA_CUSTOMIZE -from homeassistant.exceptions import ( - HomeAssistantError, - InvalidStateError, - NoEntitySpecifiedError, -) +from homeassistant.exceptions import HomeAssistantError, NoEntitySpecifiedError from homeassistant.loader import async_suggest_report_issue, bind_hass from homeassistant.util import ensure_unique_string, slugify from homeassistant.util.frozen_dataclass_compat import FrozenOrThawed @@ -1127,9 +1123,6 @@ class Entity( # Polling returned after the entity has already been removed return - hass = self.hass - entity_id = self.entity_id - if (entry := self.registry_entry) and entry.disabled_by: if not self._disabled_reported: self._disabled_reported = True @@ -1138,7 +1131,7 @@ class Entity( "Entity %s is incorrectly being triggered for updates while it" " is disabled. This is a bug in the %s integration" ), - entity_id, + self.entity_id, self.platform.platform_name, ) return @@ -1180,7 +1173,7 @@ class Entity( "Entity %s (%s) is updating its capabilities too often," " please %s" ), - entity_id, + self.entity_id, type(self), report_issue, ) @@ -1197,7 +1190,7 @@ class Entity( report_issue = self._suggest_report_issue() _LOGGER.warning( "Updating state for %s (%s) took %.3f seconds. Please %s", - entity_id, + self.entity_id, type(self), time_now - state_calculate_start, report_issue, @@ -1208,12 +1201,12 @@ class Entity( # set and since try is near zero cost # on py3.11+ its faster to assume it is # set and catch the exception if it is not. - customize = hass.data[DATA_CUSTOMIZE] + custom = self.hass.data[DATA_CUSTOMIZE].get(self.entity_id) except KeyError: pass else: # Overwrite properties that have been set in the config file. - if custom := customize.get(entity_id): + if custom: attr |= custom if ( @@ -1223,23 +1216,16 @@ class Entity( self._context = None self._context_set = None - try: - hass.states.async_set_internal( - entity_id, - state, - attr, - self.force_update, - self._context, - self._state_info, - time_now, - ) - except InvalidStateError: - _LOGGER.exception( - "Failed to set state for %s, fall back to %s", entity_id, STATE_UNKNOWN - ) - hass.states.async_set( - entity_id, STATE_UNKNOWN, {}, self.force_update, self._context - ) + # Intentionally called with positional args for performance reasons + self.hass.states.async_set_internal( + self.entity_id, + state, + attr, + self.force_update, + self._context, + self._state_info, + time_now, + ) def schedule_update_ha_state(self, force_refresh: bool = False) -> None: """Schedule an update ha state change task. diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 11a9786f86e..d4fa567e929 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -522,8 +522,14 @@ class EntityPlatform: self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: """Schedule adding entities for a single platform async.""" + entities: list[Entity] = ( + new_entities if type(new_entities) is list else list(new_entities) + ) + # handle empty list from component/platform + if not entities: + return task = self.hass.async_create_task_internal( - self.async_add_entities(new_entities, update_before_add=update_before_add), + self.async_add_entities(entities, update_before_add=update_before_add), f"EntityPlatform async_add_entities {self.domain}.{self.platform_name}", eager_start=True, ) @@ -541,10 +547,16 @@ class EntityPlatform: ) -> None: """Schedule adding entities for a single platform async and track the task.""" assert self.config_entry + entities: list[Entity] = ( + new_entities if type(new_entities) is list else list(new_entities) + ) + # handle empty list from component/platform + if not entities: + return task = self.config_entry.async_create_task( self.hass, self.async_add_entities( - new_entities, + entities, update_before_add=update_before_add, config_subentry_id=config_subentry_id, ), @@ -573,9 +585,9 @@ class EntityPlatform: async def _async_add_and_update_entities( self, - coros: list[Coroutine[Any, Any, None]], entities: list[Entity], timeout: float, + config_subentry_id: str | None, ) -> None: """Add entities for a single platform and update them. @@ -585,10 +597,21 @@ class EntityPlatform: event loop and will finish faster if we run them concurrently. """ results: list[BaseException | None] | None = None - tasks = [create_eager_task(coro, loop=self.hass.loop) for coro in coros] + entity_registry = ent_reg.async_get(self.hass) try: async with self.hass.timeout.async_timeout(timeout, self.domain): - results = await asyncio.gather(*tasks, return_exceptions=True) + results = await asyncio.gather( + *( + create_eager_task( + self._async_add_entity( + entity, True, entity_registry, config_subentry_id + ), + loop=self.hass.loop, + ) + for entity in entities + ), + return_exceptions=True, + ) except TimeoutError: self.logger.warning( "Timed out adding entities for domain %s with platform %s after %ds", @@ -615,9 +638,9 @@ class EntityPlatform: async def _async_add_entities( self, - coros: list[Coroutine[Any, Any, None]], entities: list[Entity], timeout: float, + config_subentry_id: str | None, ) -> None: """Add entities for a single platform without updating. @@ -626,13 +649,15 @@ class EntityPlatform: to the event loop so we can await the coros directly without scheduling them as tasks. """ + entity_registry = ent_reg.async_get(self.hass) try: async with self.hass.timeout.async_timeout(timeout, self.domain): - for idx, coro in enumerate(coros): + for entity in entities: try: - await coro + await self._async_add_entity( + entity, False, entity_registry, config_subentry_id + ) except Exception as ex: - entity = entities[idx] self.logger.exception( "Error adding entity %s for domain %s with platform %s", entity.entity_id, @@ -670,33 +695,16 @@ class EntityPlatform: f"entry {self.config_entry.entry_id if self.config_entry else None}" ) - # handle empty list from component/platform - if not new_entities: # type: ignore[truthy-iterable] - return - - hass = self.hass - entity_registry = ent_reg.async_get(hass) - coros: list[Coroutine[Any, Any, None]] = [] - entities: list[Entity] = [] - for entity in new_entities: - coros.append( - self._async_add_entity( - entity, update_before_add, entity_registry, config_subentry_id - ) - ) - entities.append(entity) - - # No entities for processing - if not coros: - return - - timeout = max(SLOW_ADD_ENTITY_MAX_WAIT * len(coros), SLOW_ADD_MIN_TIMEOUT) + entities: list[Entity] = ( + new_entities if type(new_entities) is list else list(new_entities) + ) + timeout = max(SLOW_ADD_ENTITY_MAX_WAIT * len(entities), SLOW_ADD_MIN_TIMEOUT) if update_before_add: - add_func = self._async_add_and_update_entities + await self._async_add_and_update_entities( + entities, timeout, config_subentry_id + ) else: - add_func = self._async_add_entities - - await add_func(coros, entities, timeout) + await self._async_add_entities(entities, timeout, config_subentry_id) if ( (self.config_entry and self.config_entry.pref_disable_polling) @@ -875,7 +883,6 @@ class EntityPlatform: get_initial_options=entity.get_initial_entity_options, has_entity_name=entity.has_entity_name, hidden_by=hidden_by, - known_object_ids=self.entities, original_device_class=entity.device_class, original_icon=entity.icon, original_name=entity_name, @@ -919,7 +926,7 @@ class EntityPlatform: f"{self.entity_namespace} {suggested_object_id}" ) entity.entity_id = entity_registry.async_generate_entity_id( - self.domain, suggested_object_id, self.entities + self.domain, suggested_object_id ) # Make sure it is valid in case an entity set the value themselves diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 684d00fe344..78a65acf290 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -11,7 +11,7 @@ timer. from __future__ import annotations from collections import defaultdict -from collections.abc import Callable, Container, Hashable, KeysView, Mapping +from collections.abc import Callable, Hashable, KeysView, Mapping from datetime import datetime, timedelta from enum import StrEnum import logging @@ -164,7 +164,7 @@ def _protect_entity_options( return ReadOnlyDict({key: ReadOnlyDict(val) for key, val in data.items()}) -@attr.s(frozen=True, slots=True) +@attr.s(frozen=True, kw_only=True, slots=True) class RegistryEntry: """Entity Registry Entry.""" @@ -175,35 +175,32 @@ class RegistryEntry: aliases: set[str] = attr.ib(factory=set) area_id: str | None = attr.ib(default=None) categories: dict[str, str] = attr.ib(factory=dict) - capabilities: Mapping[str, Any] | None = attr.ib(default=None) - config_entry_id: str | None = attr.ib(default=None) - config_subentry_id: str | None = attr.ib(default=None) - created_at: datetime = attr.ib(factory=utcnow) + capabilities: Mapping[str, Any] | None = attr.ib() + config_entry_id: str | None = attr.ib() + config_subentry_id: str | None = attr.ib() + created_at: datetime = attr.ib() device_class: str | None = attr.ib(default=None) - device_id: str | None = attr.ib(default=None) + device_id: str | None = attr.ib() domain: str = attr.ib(init=False, repr=False) - disabled_by: RegistryEntryDisabler | None = attr.ib(default=None) - entity_category: EntityCategory | None = attr.ib(default=None) - hidden_by: RegistryEntryHider | None = attr.ib(default=None) + disabled_by: RegistryEntryDisabler | None = attr.ib() + entity_category: EntityCategory | None = attr.ib() + has_entity_name: bool = attr.ib() + hidden_by: RegistryEntryHider | None = attr.ib() icon: str | None = attr.ib(default=None) id: str = attr.ib( - default=None, - converter=attr.converters.default_if_none(factory=uuid_util.random_uuid_hex), # type: ignore[misc] + converter=attr.converters.default_if_none(factory=uuid_util.random_uuid_hex) # type: ignore[misc] ) - has_entity_name: bool = attr.ib(default=False) labels: set[str] = attr.ib(factory=set) modified_at: datetime = attr.ib(factory=utcnow) name: str | None = attr.ib(default=None) - options: ReadOnlyEntityOptionsType = attr.ib( - default=None, converter=_protect_entity_options - ) + options: ReadOnlyEntityOptionsType = attr.ib(converter=_protect_entity_options) # As set by integration - original_device_class: str | None = attr.ib(default=None) - original_icon: str | None = attr.ib(default=None) - original_name: str | None = attr.ib(default=None) - supported_features: int = attr.ib(default=0) - translation_key: str | None = attr.ib(default=None) - unit_of_measurement: str | None = attr.ib(default=None) + original_device_class: str | None = attr.ib() + original_icon: str | None = attr.ib() + original_name: str | None = attr.ib() + supported_features: int = attr.ib() + translation_key: str | None = attr.ib() + unit_of_measurement: str | None = attr.ib() _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) @domain.default @@ -790,26 +787,18 @@ class EntityRegistry(BaseRegistry): """Return known device ids.""" return list(self.entities.get_device_ids()) - def _entity_id_available( - self, entity_id: str, known_object_ids: Container[str] | None - ) -> bool: + def _entity_id_available(self, entity_id: str) -> bool: """Return True if the entity_id is available. An entity_id is available if: - It's not registered - - It's not known by the entity component adding the entity - - It's not in the state machine + - It's available (not in the state machine and not reserved) Note that an entity_id which belongs to a deleted entity is considered available. """ - if known_object_ids is None: - known_object_ids = {} - - return ( - entity_id not in self.entities - and entity_id not in known_object_ids - and self.hass.states.async_available(entity_id) + return entity_id not in self.entities and self.hass.states.async_available( + entity_id ) @callback @@ -817,7 +806,6 @@ class EntityRegistry(BaseRegistry): self, domain: str, suggested_object_id: str, - known_object_ids: Container[str] | None = None, ) -> str: """Generate an entity ID that does not conflict. @@ -829,11 +817,9 @@ class EntityRegistry(BaseRegistry): raise MaxLengthExceeded(domain, "domain", MAX_LENGTH_STATE_DOMAIN) test_string = preferred_string[:MAX_LENGTH_STATE_ENTITY_ID] - if known_object_ids is None: - known_object_ids = set() tries = 1 - while not self._entity_id_available(test_string, known_object_ids): + while not self._entity_id_available(test_string): tries += 1 len_suffix = len(str(tries)) + 1 test_string = ( @@ -850,7 +836,6 @@ class EntityRegistry(BaseRegistry): unique_id: str, *, # To influence entity ID generation - known_object_ids: Container[str] | None = None, suggested_object_id: str | None = None, # To disable or hide an entity if it gets created disabled_by: RegistryEntryDisabler | None = None, @@ -924,7 +909,6 @@ class EntityRegistry(BaseRegistry): entity_id = self.async_generate_entity_id( domain, suggested_object_id or f"{platform}_{unique_id}", - known_object_ids, ) if ( @@ -1167,7 +1151,7 @@ class EntityRegistry(BaseRegistry): ) if new_entity_id is not UNDEFINED and new_entity_id != old.entity_id: - if not self._entity_id_available(new_entity_id, None): + if not self._entity_id_available(new_entity_id): raise ValueError("Entity with this ID is already registered") if not valid_entity_id(new_entity_id): diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index b363bc21e86..baf1f144a3f 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -551,6 +551,12 @@ def async_track_entity_registry_updated_event( ) +@callback +def async_has_entity_registry_updated_listeners(hass: HomeAssistant) -> bool: + """Check if async_track_entity_registry_updated_event has been called yet.""" + return _KEYED_TRACK_ENTITY_REGISTRY_UPDATED.key in hass.data + + @callback def _async_device_registry_updated_filter( hass: HomeAssistant, diff --git a/homeassistant/helpers/floor_registry.py b/homeassistant/helpers/floor_registry.py index fcfca8e3212..186ad2b31f7 100644 --- a/homeassistant/helpers/floor_registry.py +++ b/homeassistant/helpers/floor_registry.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections import defaultdict from collections.abc import Iterable import dataclasses from dataclasses import dataclass @@ -16,8 +17,9 @@ from homeassistant.util.hass_dict import HassKey from .normalized_name_base_registry import ( NormalizedNameBaseRegistryEntry, NormalizedNameBaseRegistryItems, + normalize_name, ) -from .registry import BaseRegistry +from .registry import BaseRegistry, RegistryIndexType from .singleton import singleton from .storage import Store from .typing import UNDEFINED, UndefinedType @@ -92,10 +94,43 @@ class FloorRegistryStore(Store[FloorRegistryStoreData]): return old_data # type: ignore[return-value] +class FloorRegistryItems(NormalizedNameBaseRegistryItems[FloorEntry]): + """Class to hold floor registry items.""" + + def __init__(self) -> None: + """Initialize the floor registry items.""" + super().__init__() + self._aliases_index: RegistryIndexType = defaultdict(dict) + + def _index_entry(self, key: str, entry: FloorEntry) -> None: + """Index an entry.""" + super()._index_entry(key, entry) + for alias in entry.aliases: + normalized_alias = normalize_name(alias) + self._aliases_index[normalized_alias][key] = True + + def _unindex_entry( + self, key: str, replacement_entry: FloorEntry | None = None + ) -> None: + # always call base class before other indices + super()._unindex_entry(key, replacement_entry) + entry = self.data[key] + if aliases := entry.aliases: + for alias in aliases: + normalized_alias = normalize_name(alias) + self._unindex_entry_value(key, normalized_alias, self._aliases_index) + + def get_floors_for_alias(self, alias: str) -> list[FloorEntry]: + """Get floors for alias.""" + data = self.data + normalized_alias = normalize_name(alias) + return [data[key] for key in self._aliases_index.get(normalized_alias, ())] + + class FloorRegistry(BaseRegistry[FloorRegistryStoreData]): """Class to hold a registry of floors.""" - floors: NormalizedNameBaseRegistryItems[FloorEntry] + floors: FloorRegistryItems _floor_data: dict[str, FloorEntry] def __init__(self, hass: HomeAssistant) -> None: @@ -123,6 +158,11 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]): """Get floor by name.""" return self.floors.get_by_name(name) + @callback + def async_get_floors_by_alias(self, alias: str) -> list[FloorEntry]: + """Get floors by alias.""" + return self.floors.get_floors_for_alias(alias) + @callback def async_list_floors(self) -> Iterable[FloorEntry]: """Get all floors.""" @@ -226,7 +266,7 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]): async def async_load(self) -> None: """Load the floor registry.""" data = await self._store.async_load() - floors = NormalizedNameBaseRegistryItems[FloorEntry]() + floors = FloorRegistryItems() if data is not None: for floor in data["floors"]: diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index df94788a981..adf113e0f30 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -9,6 +9,7 @@ from datetime import timedelta from decimal import Decimal from enum import Enum from functools import cache, partial +from operator import attrgetter from typing import Any, cast import slugify as unicode_slug @@ -23,6 +24,7 @@ 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 from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN, TodoServices from homeassistant.components.weather import INTENT_GET_WEATHER from homeassistant.const import ( ATTR_DOMAIN, @@ -122,15 +124,29 @@ def async_register_api(hass: HomeAssistant, api: API) -> Callable[[], None]: async def async_get_api( - hass: HomeAssistant, api_id: str, llm_context: LLMContext + hass: HomeAssistant, api_id: str | list[str], llm_context: LLMContext ) -> APIInstance: - """Get an API.""" + """Get an API. + + This returns a single APIInstance for one or more API ids, merging into + a single instance of necessary. + """ apis = _async_get_apis(hass) - if api_id not in apis: - raise HomeAssistantError(f"API {api_id} not found") + if isinstance(api_id, str): + api_id = [api_id] - return await apis[api_id].async_get_api_instance(llm_context) + for key in api_id: + if key not in apis: + raise HomeAssistantError(f"API {key} not found") + + api: API + if len(api_id) == 1: + api = apis[api_id[0]] + else: + api = MergedAPI([apis[key] for key in api_id]) + + return await api.async_get_api_instance(llm_context) @callback @@ -298,6 +314,102 @@ class IntentTool(Tool): return response +class NamespacedTool(Tool): + """A tool that wraps another tool, prepending a namespace. + + This is used to support tools from multiple API. This tool dispatches + the original tool with the original non-namespaced name. + """ + + def __init__(self, namespace: str, tool: Tool) -> None: + """Init the class.""" + self.namespace = namespace + self.name = f"{namespace}.{tool.name}" + self.description = tool.description + self.parameters = tool.parameters + self.tool = tool + + async def async_call( + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext + ) -> JsonObjectType: + """Handle the intent.""" + return await self.tool.async_call( + hass, + ToolInput( + tool_name=self.tool.name, + tool_args=tool_input.tool_args, + id=tool_input.id, + ), + llm_context, + ) + + +class MergedAPI(API): + """An API that represents a merged view of multiple APIs.""" + + def __init__(self, llm_apis: list[API]) -> None: + """Init the class.""" + if not llm_apis: + raise ValueError("No APIs provided") + hass = llm_apis[0].hass + api_ids = [unicode_slug.slugify(api.id) for api in llm_apis] + if len(set(api_ids)) != len(api_ids): + raise ValueError("API IDs must be unique") + super().__init__( + hass=hass, + id="|".join(unicode_slug.slugify(api.id) for api in llm_apis), + name="Merged LLM API", + ) + self.llm_apis = llm_apis + + async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance: + """Return the instance of the API.""" + # These usually don't do I/O and execute right away + llm_apis = [ + await llm_api.async_get_api_instance(llm_context) + for llm_api in self.llm_apis + ] + prompt_parts = [] + tools: list[Tool] = [] + for api_instance in llm_apis: + namespace = unicode_slug.slugify(api_instance.api.name) + prompt_parts.append( + f'Follow these instructions for tools from "{namespace}":\n' + ) + prompt_parts.append(api_instance.api_prompt) + prompt_parts.append("\n\n") + tools.extend( + [NamespacedTool(namespace, tool) for tool in api_instance.tools] + ) + + return APIInstance( + api=self, + api_prompt="".join(prompt_parts), + llm_context=llm_context, + tools=tools, + custom_serializer=self._custom_serializer(llm_apis), + ) + + def _custom_serializer( + self, llm_apis: list[APIInstance] + ) -> Callable[[Any], Any] | None: + serializers = [ + api_instance.custom_serializer + for api_instance in llm_apis + if api_instance.custom_serializer is not None + ] + if not serializers: + return None + + def merged(x: Any) -> Any: + for serializer in serializers: + if (result := serializer(x)) is not None: + return result + return x + + return merged + + class AssistAPI(API): """API exposing Assist API to LLMs.""" @@ -466,6 +578,14 @@ class AssistAPI(API): names.extend(info["names"].split(", ")) tools.append(CalendarGetEventsTool(names)) + if exposed_domains is not None and TODO_DOMAIN in exposed_domains: + names = [] + for info in exposed_entities["entities"].values(): + if info["domain"] != TODO_DOMAIN: + continue + names.extend(info["names"].split(", ")) + tools.append(TodoGetItemsTool(names)) + tools.extend( ScriptTool(self.hass, script_entity_id) for script_entity_id in exposed_entities[SCRIPT_DOMAIN] @@ -511,7 +631,7 @@ def _get_exposed_entities( CALENDAR_DOMAIN: {}, } - for state in hass.states.async_all(): + for state in sorted(hass.states.async_all(), key=attrgetter("name")): if not async_should_expose(hass, assistant, state.entity_id): continue @@ -913,6 +1033,65 @@ class CalendarGetEventsTool(Tool): return {"success": True, "result": events} +class TodoGetItemsTool(Tool): + """LLM Tool allowing querying a to-do list.""" + + name = "todo_get_items" + description = ( + "Query a to-do list to find out what items are on it. " + "Use this to answer questions like 'What's on my task list?' or 'Read my grocery list'. " + "Filters items by status (needs_action, completed, all)." + ) + + def __init__(self, todo_lists: list[str]) -> None: + """Init the get items tool.""" + self.parameters = vol.Schema( + { + vol.Required("todo_list"): vol.In(todo_lists), + vol.Optional( + "status", + description="Filter returned items by status, by default returns incomplete items", + default="needs_action", + ): vol.In(["needs_action", "completed", "all"]), + } + ) + + async def async_call( + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext + ) -> JsonObjectType: + """Query a to-do list.""" + data = self.parameters(tool_input.tool_args) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name=data["todo_list"], + domains=[TODO_DOMAIN], + assistant=llm_context.assistant, + ), + ) + if not result.is_match: + return {"success": False, "error": "To-do list not found"} + entity_id = result.states[0].entity_id + service_data: dict[str, Any] = {"entity_id": entity_id} + if status := data.get("status"): + if status == "all": + service_data["status"] = ["needs_action", "completed"] + else: + service_data["status"] = [status] + service_result = await hass.services.async_call( + TODO_DOMAIN, + TodoServices.GET_ITEMS, + service_data, + context=llm_context.context, + blocking=True, + return_response=True, + ) + if not service_result: + return {"success": False, "error": "To-do list not found"} + items = cast(dict, service_result)[entity_id]["items"] + return {"success": True, "result": items} + + class GetLiveContextTool(Tool): """Tool for getting the current state of exposed entities. @@ -923,10 +1102,10 @@ class GetLiveContextTool(Tool): name = "GetLiveContext" description = ( - "Use this tool when the user asks a question about the CURRENT state, " - "value, or mode of a specific device, sensor, entity, or area in the " - "smart home, and the answer can be improved with real-time data not " - "available in the static device overview list. " + "Provides real-time information about the CURRENT state, value, or mode of devices, sensors, entities, or areas. " + "Use this tool for: " + "1. Answering questions about current conditions (e.g., 'Is the light on?'). " + "2. As the first step in conditional actions (e.g., 'If the weather is rainy, turn off sprinklers' requires checking the weather first)." ) async def async_call( diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index e39cc2de547..67c4448724e 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -10,12 +10,12 @@ from aiohttp import hdrs from hass_nabucasa import remote import yarl -from homeassistant.components import http from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass from homeassistant.util.network import is_ip_address, is_loopback, normalize_url +from . import http from .hassio import is_hassio TYPE_URL_INTERNAL = "internal_url" diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 43429bdb1d2..2b4da38b15e 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -629,6 +629,10 @@ class _ScriptRun: self, script: Script, *, parallel: bool = False ) -> None: """Execute a script.""" + if not script.enabled: + self._log("Skipping disabled script: %s", script.name) + trace_set_result(enabled=False) + return result = await self._async_run_long_action( self._hass.async_create_task_internal( script.async_run( @@ -1442,8 +1446,12 @@ class Script: script_mode: str = DEFAULT_SCRIPT_MODE, top_level: bool = True, variables: ScriptVariables | None = None, + enabled: bool = True, ) -> None: - """Initialize the script.""" + """Initialize the script. + + enabled attribute is only used for non-top-level scripts. + """ if not (all_scripts := hass.data.get(DATA_SCRIPTS)): all_scripts = hass.data[DATA_SCRIPTS] = [] hass.bus.async_listen_once( @@ -1462,6 +1470,7 @@ class Script: self.name = name self.unique_id = f"{domain}.{name}-{id(self)}" self.domain = domain + self.enabled = enabled self.running_description = running_description or f"{domain} script" self._change_listener = change_listener self._change_listener_job = ( @@ -2002,6 +2011,7 @@ class Script: max_runs=self.max_runs, logger=self._logger, top_level=False, + enabled=parallel_script.get(CONF_ENABLED, True), ) parallel_script.change_listener = partial( self._chain_change_listener, parallel_script diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 70a94cfaaa9..cb6d8fe81b8 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1072,7 +1072,7 @@ class TemplateStateBase(State): raise KeyError @under_cached_property - def entity_id(self) -> str: # type: ignore[override] + def entity_id(self) -> str: """Wrap State.entity_id. Intentionally does not collect state @@ -1128,7 +1128,7 @@ class TemplateStateBase(State): return self._state.object_id @property - def name(self) -> str: # type: ignore[override] + def name(self) -> str: """Wrap State.name.""" self._collect_state() return self._state.name @@ -1413,6 +1413,28 @@ def device_id(hass: HomeAssistant, entity_id_or_device_name: str) -> str | None: ) +def device_name(hass: HomeAssistant, lookup_value: str) -> str | None: + """Get the device name from an device id, or entity id.""" + device_reg = device_registry.async_get(hass) + if device := device_reg.async_get(lookup_value): + return device.name_by_user or device.name + + ent_reg = entity_registry.async_get(hass) + # Import here, not at top-level to avoid circular import + from . import config_validation as cv # pylint: disable=import-outside-toplevel + + try: + cv.entity_id(lookup_value) + except vol.Invalid: + pass + else: + if entity := ent_reg.async_get(lookup_value): + if entity.device_id and (device := device_reg.async_get(entity.device_id)): + return device.name_by_user or device.name + + return None + + def device_attr(hass: HomeAssistant, device_or_entity_id: str, attr_name: str) -> Any: """Get the device specific attribute.""" device_reg = device_registry.async_get(hass) @@ -1478,10 +1500,14 @@ def floors(hass: HomeAssistant) -> Iterable[str | None]: def floor_id(hass: HomeAssistant, lookup_value: Any) -> str | None: - """Get the floor ID from a floor name.""" + """Get the floor ID from a floor or area name, alias, device id, or entity id.""" floor_registry = fr.async_get(hass) - if floor := floor_registry.async_get_floor_by_name(str(lookup_value)): + lookup_str = str(lookup_value) + if floor := floor_registry.async_get_floor_by_name(lookup_str): return floor.floor_id + floors_list = floor_registry.async_get_floors_by_alias(lookup_str) + if floors_list: + return floors_list[0].floor_id if aid := area_id(hass, lookup_value): area_reg = area_registry.async_get(hass) @@ -1541,10 +1567,14 @@ def areas(hass: HomeAssistant) -> Iterable[str | None]: def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: - """Get the area ID from an area name, device id, or entity id.""" + """Get the area ID from an area name, alias, device id, or entity id.""" area_reg = area_registry.async_get(hass) - if area := area_reg.async_get_area_by_name(str(lookup_value)): + lookup_str = str(lookup_value) + if area := area_reg.async_get_area_by_name(lookup_str): return area.id + areas_list = area_reg.async_get_areas_by_alias(lookup_str) + if areas_list: + return areas_list[0].id ent_reg = entity_registry.async_get(hass) dev_reg = device_registry.async_get(hass) @@ -3222,6 +3252,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): # Device extensions + self.globals["device_name"] = hassfunction(device_name) + self.filters["device_name"] = self.globals["device_name"] + self.globals["device_attr"] = hassfunction(device_attr) self.filters["device_attr"] = self.globals["device_attr"] diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index 1486e33d6fa..bf7598eb024 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -2,10 +2,11 @@ from __future__ import annotations -import contextlib +import itertools import logging from typing import Any +import jinja2 import voluptuous as vol from homeassistant.components.sensor import ( @@ -30,7 +31,14 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from . import config_validation as cv from .entity import Entity -from .template import TemplateStateFromEntityId, render_complex +from .template import ( + _SENTINEL, + Template, + TemplateStateFromEntityId, + _render_with_context, + render_complex, + result_as_boolean, +) from .typing import ConfigType CONF_AVAILABILITY = "availability" @@ -65,6 +73,27 @@ def make_template_entity_base_schema(default_name: str) -> vol.Schema: ) +def log_triggered_template_error( + entity_id: str, + err: TemplateError, + key: str | None = None, + attribute: str | None = None, +) -> None: + """Log a trigger entity template error.""" + target = "" + if key: + target = f" {key}" + elif attribute: + target = f" {CONF_ATTRIBUTES}.{attribute}" + + logging.getLogger(f"{__package__}.{entity_id.split('.')[0]}").error( + "Error rendering%s template for %s: %s", + target, + entity_id, + err, + ) + + TEMPLATE_SENSOR_BASE_SCHEMA = vol.Schema( { vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, @@ -74,6 +103,44 @@ TEMPLATE_SENSOR_BASE_SCHEMA = vol.Schema( ).extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema) +class ValueTemplate(Template): + """Class to hold a value_template and manage caching and rendering it with 'value' in variables.""" + + @classmethod + def from_template(cls, template: Template) -> ValueTemplate: + """Create a ValueTemplate object from a Template object.""" + return cls(template.template, template.hass) + + @callback + def async_render_as_value_template( + self, entity_id: str, variables: dict[str, Any], error_value: Any + ) -> Any: + """Render template that requires 'value' and optionally 'value_json'. + + Template errors will be suppressed when an error_value is supplied. + + This method must be run in the event loop. + """ + self._renders += 1 + + if self.is_static: + return self.template + + compiled = self._compiled or self._ensure_compiled() + + try: + render_result = _render_with_context( + self.template, compiled, **variables + ).strip() + except jinja2.TemplateError as ex: + message = f"Error parsing value for {entity_id}: {ex} (value: {variables['value']}, template: {self.template})" + logger = logging.getLogger(f"{__package__}.{entity_id.split('.')[0]}") + logger.debug(message) + return error_value + + return render_result + + class TriggerBaseEntity(Entity): """Template Base entity based on trigger data.""" @@ -122,6 +189,9 @@ class TriggerBaseEntity(Entity): self._parse_result = {CONF_AVAILABILITY} self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._availability_template = config.get(CONF_AVAILABILITY) + self._available = True + @property def name(self) -> str | None: """Name of the entity.""" @@ -145,12 +215,10 @@ class TriggerBaseEntity(Entity): @property def available(self) -> bool: """Return availability of the entity.""" - return ( - self._rendered is not self._static_rendered - and - # Check against False so `None` is ok - self._rendered.get(CONF_AVAILABILITY) is not False - ) + if self._availability_template is None: + return True + + return self._available @property def extra_state_attributes(self) -> dict[str, Any] | None: @@ -176,35 +244,93 @@ class TriggerBaseEntity(Entity): extra_state_attributes[attr] = last_state.attributes[attr] self._rendered[CONF_ATTRIBUTES] = extra_state_attributes + def _template_variables(self, run_variables: dict[str, Any] | None = None) -> dict: + """Render template variables.""" + return { + "this": TemplateStateFromEntityId(self.hass, self.entity_id), + **(run_variables or {}), + } + + def _render_single_template( + self, + key: str, + variables: dict[str, Any], + strict: bool = False, + ) -> Any: + """Render a single template.""" + try: + if key in self._to_render_complex: + return render_complex(self._config[key], variables) + + return self._config[key].async_render( + variables, parse_result=key in self._parse_result, strict=strict + ) + except TemplateError as err: + log_triggered_template_error(self.entity_id, err, key=key) + + return _SENTINEL + + def _render_availability_template(self, variables: dict[str, Any]) -> bool: + """Render availability template.""" + if not self._availability_template: + return True + + try: + if ( + available := self._availability_template.async_render( + variables, parse_result=True, strict=True + ) + ) is False: + self._rendered = dict(self._static_rendered) + + self._available = result_as_boolean(available) + + except TemplateError as err: + # The entity will be available when an error is rendered. This + # ensures functionality is consistent between template and trigger template + # entities. + self._available = True + log_triggered_template_error(self.entity_id, err, key=CONF_AVAILABILITY) + + return self._available + + def _render_attributes(self, rendered: dict, variables: dict[str, Any]) -> None: + """Render template attributes.""" + if CONF_ATTRIBUTES in self._config: + attributes = {} + for attribute, attribute_template in self._config[CONF_ATTRIBUTES].items(): + try: + value = render_complex(attribute_template, variables) + attributes[attribute] = value + variables.update({attribute: value}) + except TemplateError as err: + log_triggered_template_error( + self.entity_id, err, attribute=attribute + ) + rendered[CONF_ATTRIBUTES] = attributes + + def _render_single_templates( + self, + rendered: dict, + variables: dict[str, Any], + filtered: list[str] | None = None, + ) -> None: + """Render all single templates.""" + for key in itertools.chain(self._to_render_simple, self._to_render_complex): + if filtered and key in filtered: + continue + + if ( + result := self._render_single_template(key, variables) + ) is not _SENTINEL: + rendered[key] = result + def _render_templates(self, variables: dict[str, Any]) -> None: """Render templates.""" - try: - rendered = dict(self._static_rendered) - - for key in self._to_render_simple: - rendered[key] = self._config[key].async_render( - variables, - parse_result=key in self._parse_result, - ) - - for key in self._to_render_complex: - rendered[key] = render_complex( - self._config[key], - variables, - ) - - if CONF_ATTRIBUTES in self._config: - rendered[CONF_ATTRIBUTES] = render_complex( - self._config[CONF_ATTRIBUTES], - variables, - ) - - self._rendered = rendered - except TemplateError as err: - logging.getLogger(f"{__package__}.{self.entity_id.split('.')[0]}").error( - "Error rendering %s template for %s: %s", key, self.entity_id, err - ) - self._rendered = self._static_rendered + rendered = dict(self._static_rendered) + self._render_single_templates(rendered, variables) + self._render_attributes(rendered, variables) + self._rendered = rendered class ManualTriggerEntity(TriggerBaseEntity): @@ -223,23 +349,31 @@ class ManualTriggerEntity(TriggerBaseEntity): parse_result=CONF_NAME in self._parse_result, ) + def _template_variables_with_value( + self, value: str | None = None + ) -> dict[str, Any]: + """Render template variables. + + Implementing class should call this first in update method to render variables for templates. + Ex: variables = self._render_template_variables_with_value(payload) + """ + run_variables: dict[str, Any] = {"value": value} + + # Silently try if variable is a json and store result in `value_json` if it is. + try: # noqa: SIM105 - suppress is much slower + run_variables["value_json"] = json_loads(value) # type: ignore[arg-type] + except JSON_DECODE_EXCEPTIONS: + pass + + return self._template_variables(run_variables) + @callback - def _process_manual_data(self, value: Any | None = None) -> None: + def _process_manual_data(self, variables: dict[str, Any]) -> None: """Process new data manually. Implementing class should call this last in update method to render templates. - Ex: self._process_manual_data(payload) + Ex: self._process_manual_data(variables) """ - - run_variables: dict[str, Any] = {"value": value} - # Silently try if variable is a json and store result in `value_json` if it is. - with contextlib.suppress(*JSON_DECODE_EXCEPTIONS): - run_variables["value_json"] = json_loads(run_variables["value"]) - variables = { - "this": TemplateStateFromEntityId(self.hass, self.entity_id), - **(run_variables or {}), - } - self._render_templates(variables) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 20763dc7b30..0980a6f2ba9 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -18,7 +18,7 @@ import pathlib import sys import time from types import ModuleType -from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, cast +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, cast, final from awesomeversion import ( AwesomeVersion, @@ -646,6 +646,7 @@ def async_register_preload_platform(hass: HomeAssistant, platform_name: str) -> preload_platforms.append(platform_name) +@final # Final to allow direct checking of the type instead of using isinstance class Integration: """An integration in Home Assistant.""" @@ -1446,31 +1447,13 @@ async def resolve_integrations_dependencies( Detects circular dependencies and missing integrations. """ - resolved = _ResolveDependenciesCache() - - async def _resolve_deps_catch_exceptions(itg: Integration) -> set[str] | None: - try: - return await _do_resolve_dependencies(itg, cache=resolved) - except Exception as exc: # noqa: BLE001 - _LOGGER.error("Unable to resolve dependencies for %s: %s", itg.domain, exc) - return None - - resolve_dependencies_tasks = { - itg.domain: create_eager_task( - _resolve_deps_catch_exceptions(itg), - name=f"resolve dependencies {itg.domain}", - loop=hass.loop, - ) - for itg in integrations - } - - result = await asyncio.gather(*resolve_dependencies_tasks.values()) - - return { - domain: deps - for domain, deps in zip(resolve_dependencies_tasks, result, strict=True) - if deps is not None - } + return await _resolve_integrations_dependencies( + hass, + "resolve dependencies", + integrations, + cache=_ResolveDependenciesCache(), + ignore_exceptions=False, + ) async def resolve_integrations_after_dependencies( @@ -1484,26 +1467,46 @@ async def resolve_integrations_after_dependencies( Detects circular dependencies and missing integrations. """ - resolved: dict[Integration, set[str] | Exception] = {} + return await _resolve_integrations_dependencies( + hass, + "resolve (after) dependencies", + integrations, + cache={}, + possible_after_dependencies=possible_after_dependencies, + ignore_exceptions=ignore_exceptions, + ) + + +async def _resolve_integrations_dependencies( + hass: HomeAssistant, + name: str, + integrations: Iterable[Integration], + *, + cache: _ResolveDependenciesCacheProtocol, + possible_after_dependencies: set[str] | None | UndefinedType = UNDEFINED, + ignore_exceptions: bool, +) -> dict[str, set[str]]: + """Resolve all dependencies, possibly including after_dependencies, for integrations. + + Detects circular dependencies and missing integrations. + """ async def _resolve_deps_catch_exceptions(itg: Integration) -> set[str] | None: try: - return await _do_resolve_dependencies( + return await _resolve_integration_dependencies( itg, - cache=resolved, + cache=cache, possible_after_dependencies=possible_after_dependencies, ignore_exceptions=ignore_exceptions, ) except Exception as exc: # noqa: BLE001 - _LOGGER.error( - "Unable to resolve (after) dependencies for %s: %s", itg.domain, exc - ) + _LOGGER.error("Unable to %s for %s: %s", name, itg.domain, exc) return None resolve_dependencies_tasks = { itg.domain: create_eager_task( _resolve_deps_catch_exceptions(itg), - name=f"resolve after dependencies {itg.domain}", + name=f"{name} {itg.domain}", loop=hass.loop, ) for itg in integrations @@ -1518,7 +1521,7 @@ async def resolve_integrations_after_dependencies( } -async def _do_resolve_dependencies( +async def _resolve_integration_dependencies( itg: Integration, *, cache: _ResolveDependenciesCacheProtocol, @@ -1541,7 +1544,7 @@ async def _do_resolve_dependencies( resolved = cache resolving: set[str] = set() - async def do_resolve_dependencies_impl(itg: Integration) -> set[str]: + async def resolve_dependencies_impl(itg: Integration) -> set[str]: domain = itg.domain # If it's already resolved, no point doing it again. @@ -1583,7 +1586,7 @@ async def _do_resolve_dependencies( all_dependencies.add(dep_domain) try: - dep_dependencies = await do_resolve_dependencies_impl(dep_integration) + dep_dependencies = await resolve_dependencies_impl(dep_integration) except CircularDependency as exc: exc.extend_cycle(domain) resolved[itg] = exc @@ -1599,7 +1602,7 @@ async def _do_resolve_dependencies( resolved[itg] = all_dependencies return all_dependencies - return await do_resolve_dependencies_impl(itg) + return await resolve_dependencies_impl(itg) class LoaderError(Exception): @@ -1707,76 +1710,6 @@ class ModuleWrapper: return value -class Components: - """Helper to load components.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the Components class.""" - self._hass = hass - - def __getattr__(self, comp_name: str) -> ModuleWrapper: - """Fetch a component.""" - # Test integration cache - integration = self._hass.data[DATA_INTEGRATIONS].get(comp_name) - - if isinstance(integration, Integration): - component: ComponentProtocol | None = integration.get_component() - else: - # Fallback to importing old-school - component = _load_file(self._hass, comp_name, _lookup_path(self._hass)) - - if component is None: - raise ImportError(f"Unable to load {comp_name}") - - # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from .helpers.frame import ReportBehavior, report_usage - - report_usage( - f"accesses hass.components.{comp_name}, which" - f" should be updated to import functions used from {comp_name} directly", - core_behavior=ReportBehavior.IGNORE, - core_integration_behavior=ReportBehavior.IGNORE, - custom_integration_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2025.3", - ) - - wrapped = ModuleWrapper(self._hass, component) - setattr(self, comp_name, wrapped) - return wrapped - - -class Helpers: - """Helper to load helpers.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the Helpers class.""" - self._hass = hass - - def __getattr__(self, helper_name: str) -> ModuleWrapper: - """Fetch a helper.""" - helper = importlib.import_module(f"homeassistant.helpers.{helper_name}") - - # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from .helpers.frame import ReportBehavior, report_usage - - report_usage( - ( - f"accesses hass.helpers.{helper_name}, which" - f" should be updated to import functions used from {helper_name} directly" - ), - core_behavior=ReportBehavior.IGNORE, - core_integration_behavior=ReportBehavior.IGNORE, - custom_integration_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2025.5", - ) - - wrapped = ModuleWrapper(self._hass, helper) - setattr(self, helper_name, wrapped) - return wrapped - - def bind_hass[_CallableT: Callable[..., Any]](func: _CallableT) -> _CallableT: """Decorate function to indicate that first argument is hass. diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 95502bf257c..48b21942f4d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,11 +2,11 @@ aiodhcpwatcher==1.1.1 aiodiscover==2.6.1 -aiodns==3.2.0 -aiohasupervisor==0.3.0 +aiodns==3.3.0 +aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.11.16 +aiohttp==3.11.18 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 @@ -23,34 +23,34 @@ bcrypt==4.2.0 bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 -bluetooth-auto-recovery==1.4.5 -bluetooth-data-tools==1.26.5 +bluetooth-auto-recovery==1.5.1 +bluetooth-data-tools==1.28.1 cached-ipaddress==0.10.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 dbus-fast==2.43.0 -fnv-hash-fast==1.4.0 +fnv-hash-fast==1.5.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.37.0 -hass-nabucasa==0.94.0 +habluetooth==3.48.2 +hass-nabucasa==0.96.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250411.0 -home-assistant-intents==2025.3.28 +home-assistant-frontend==20250507.0 +home-assistant-intents==2025.5.7 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 numpy==2.2.2 -orjson==3.10.16 +orjson==3.10.18 packaging>=23.1 paho-mqtt==2.1.0 -Pillow==11.1.0 -propcache==0.3.0 +Pillow==11.2.1 +propcache==0.3.1 psutil-home-assistant==0.0.1 PyJWT==2.10.1 pymicro-vad==1.0.1 @@ -63,19 +63,19 @@ PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 securetar==2025.2.1 -SQLAlchemy==2.0.39 +SQLAlchemy==2.0.40 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.13.0,<5.0 ulid-transform==1.4.0 urllib3>=1.26.5,<2 -uv==0.6.10 -voluptuous-openapi==0.0.6 +uv==0.7.1 +voluptuous-openapi==0.0.7 voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 -yarl==1.18.3 -zeroconf==0.146.0 +yarl==1.20.0 +zeroconf==0.147.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 @@ -88,9 +88,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.67.1 -grpcio-status==1.67.1 -grpcio-reflection==1.67.1 +grpcio==1.71.0 +grpcio-status==1.71.0 +grpcio-reflection==1.71.0 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 @@ -130,7 +130,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.10.6 +pydantic==2.11.3 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index ca3df5080b5..981f0a26926 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -29,7 +29,7 @@ from homeassistant.helpers.check_config import async_check_ha_config_file # mypy: allow-untyped-calls, allow-untyped-defs -REQUIREMENTS = ("colorlog==6.8.2",) +REQUIREMENTS = ("colorlog==6.9.0",) _LOGGER = logging.getLogger(__name__) MOCKS: dict[str, tuple[str, Callable]] = { diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 334e3a9e074..39f0a7656f3 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -45,36 +45,36 @@ _LOGGER = logging.getLogger(__name__) ATTR_COMPONENT: Final = "component" -# DATA_SETUP is a dict, indicating domains which are currently +# _DATA_SETUP is a dict, indicating domains which are currently # being setup or which failed to setup: -# - Tasks are added to DATA_SETUP by `async_setup_component`, the key is the domain +# - Tasks are added to _DATA_SETUP by `async_setup_component`, the key is the domain # being setup and the Task is the `_async_setup_component` helper. -# - Tasks are removed from DATA_SETUP if setup was successful, that is, +# - Tasks are removed from _DATA_SETUP if setup was successful, that is, # the task returned True. -DATA_SETUP: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_tasks") +_DATA_SETUP: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_tasks") -# DATA_SETUP_DONE is a dict, indicating components which will be setup: -# - Events are added to DATA_SETUP_DONE during bootstrap by +# _DATA_SETUP_DONE is a dict, indicating components which will be setup: +# - Events are added to _DATA_SETUP_DONE during bootstrap by # async_set_domains_to_be_loaded, the key is the domain which will be loaded. -# - Events are set and removed from DATA_SETUP_DONE when async_setup_component +# - Events are set and removed from _DATA_SETUP_DONE when async_setup_component # is finished, regardless of if the setup was successful or not. -DATA_SETUP_DONE: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_done") +_DATA_SETUP_DONE: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_done") -# DATA_SETUP_STARTED is a dict, indicating when an attempt +# _DATA_SETUP_STARTED is a dict, indicating when an attempt # to setup a component started. -DATA_SETUP_STARTED: HassKey[dict[tuple[str, str | None], float]] = HassKey( +_DATA_SETUP_STARTED: HassKey[dict[tuple[str, str | None], float]] = HassKey( "setup_started" ) -# DATA_SETUP_TIME is a defaultdict, indicating how time was spent +# _DATA_SETUP_TIME is a defaultdict, indicating how time was spent # setting up a component. -DATA_SETUP_TIME: HassKey[ +_DATA_SETUP_TIME: HassKey[ defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]] ] = HassKey("setup_time") -DATA_DEPS_REQS: HassKey[set[str]] = HassKey("deps_reqs_processed") +_DATA_DEPS_REQS: HassKey[set[str]] = HassKey("deps_reqs_processed") -DATA_PERSISTENT_ERRORS: HassKey[dict[str, str | None]] = HassKey( +_DATA_PERSISTENT_ERRORS: HassKey[dict[str, str | None]] = HassKey( "bootstrap_persistent_errors" ) @@ -104,8 +104,8 @@ def async_notify_setup_error( # pylint: disable-next=import-outside-toplevel from .components import persistent_notification - if (errors := hass.data.get(DATA_PERSISTENT_ERRORS)) is None: - errors = hass.data[DATA_PERSISTENT_ERRORS] = {} + if (errors := hass.data.get(_DATA_PERSISTENT_ERRORS)) is None: + errors = hass.data[_DATA_PERSISTENT_ERRORS] = {} errors[component] = errors.get(component) or display_link @@ -131,8 +131,8 @@ def async_set_domains_to_be_loaded(hass: core.HomeAssistant, domains: set[str]) - Properly handle after_dependencies. - Keep track of domains which will load but have not yet finished loading """ - setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {}) - setup_futures = hass.data.setdefault(DATA_SETUP, {}) + setup_done_futures = hass.data.setdefault(_DATA_SETUP_DONE, {}) + setup_futures = hass.data.setdefault(_DATA_SETUP, {}) old_domains = set(setup_futures) | set(setup_done_futures) | hass.config.components if overlap := old_domains & domains: _LOGGER.debug("Domains to be loaded %s already loaded or pending", overlap) @@ -158,8 +158,8 @@ async def async_setup_component( if domain in hass.config.components: return True - setup_futures = hass.data.setdefault(DATA_SETUP, {}) - setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {}) + setup_futures = hass.data.setdefault(_DATA_SETUP, {}) + setup_done_futures = hass.data.setdefault(_DATA_SETUP_DONE, {}) if existing_setup_future := setup_futures.get(domain): return await existing_setup_future @@ -200,30 +200,42 @@ async def _async_process_dependencies( Returns a list of dependencies which failed to set up. """ - setup_futures = hass.data.setdefault(DATA_SETUP, {}) + setup_futures = hass.data.setdefault(_DATA_SETUP, {}) - dependencies_tasks = { - dep: setup_futures.get(dep) - or create_eager_task( - async_setup_component(hass, dep, config), - name=f"setup {dep} as dependency of {integration.domain}", - loop=hass.loop, - ) - for dep in integration.dependencies - if dep not in hass.config.components - } + dependencies_tasks: dict[str, asyncio.Future[bool]] = {} - after_dependencies_tasks: dict[str, asyncio.Future[bool]] = {} - to_be_loaded = hass.data.get(DATA_SETUP_DONE, {}) + for dep in integration.dependencies: + fut = setup_futures.get(dep) + if fut is None: + if dep in hass.config.components: + continue + fut = create_eager_task( + async_setup_component(hass, dep, config), + name=f"setup {dep} as dependency of {integration.domain}", + loop=hass.loop, + ) + dependencies_tasks[dep] = fut + + to_be_loaded = hass.data.get(_DATA_SETUP_DONE, {}) + # We don't want to just wait for the futures from `to_be_loaded` here. + # We want to ensure that our after_dependencies are always actually + # scheduled to be set up, as if for whatever reason they had not been, + # we would deadlock waiting for them here. for dep in integration.after_dependencies: - if ( - dep not in dependencies_tasks - and dep in to_be_loaded - and dep not in hass.config.components - ): - after_dependencies_tasks[dep] = to_be_loaded[dep] + if dep not in to_be_loaded or dep in dependencies_tasks: + continue + fut = setup_futures.get(dep) + if fut is None: + if dep in hass.config.components: + continue + fut = create_eager_task( + async_setup_component(hass, dep, config), + name=f"setup {dep} as after dependency of {integration.domain}", + loop=hass.loop, + ) + dependencies_tasks[dep] = fut - if not dependencies_tasks and not after_dependencies_tasks: + if not dependencies_tasks: return [] if dependencies_tasks: @@ -232,17 +244,9 @@ async def _async_process_dependencies( integration.domain, dependencies_tasks.keys(), ) - if after_dependencies_tasks: - _LOGGER.debug( - "Dependency %s will wait for after dependencies %s", - integration.domain, - after_dependencies_tasks.keys(), - ) async with hass.timeout.async_freeze(integration.domain): - results = await asyncio.gather( - *dependencies_tasks.values(), *after_dependencies_tasks.values() - ) + results = await asyncio.gather(*dependencies_tasks.values()) failed = [ domain for idx, domain in enumerate(dependencies_tasks) if not results[idx] @@ -387,7 +391,7 @@ async def _async_setup_component( }, ) - _LOGGER.debug("Setting up %s", domain) + _LOGGER.info("Setting up %s", domain) with async_start_setup(hass, integration=domain, phase=SetupPhases.SETUP): if hasattr(component, "PLATFORM_SCHEMA"): @@ -479,7 +483,7 @@ async def _async_setup_component( ) # Cleanup - hass.data[DATA_SETUP].pop(domain, None) + hass.data[_DATA_SETUP].pop(domain, None) hass.bus.async_fire_internal( EVENT_COMPONENT_LOADED, EventComponentLoaded(component=domain) @@ -569,8 +573,8 @@ async def async_process_deps_reqs( Module is a Python module of either a component or platform. """ - if (processed := hass.data.get(DATA_DEPS_REQS)) is None: - processed = hass.data[DATA_DEPS_REQS] = set() + if (processed := hass.data.get(_DATA_DEPS_REQS)) is None: + processed = hass.data[_DATA_DEPS_REQS] = set() elif integration.domain in processed: return @@ -685,7 +689,7 @@ class SetupPhases(StrEnum): """Wait time for the packages to import.""" -@singleton.singleton(DATA_SETUP_STARTED) +@singleton.singleton(_DATA_SETUP_STARTED) def _setup_started( hass: core.HomeAssistant, ) -> dict[tuple[str, str | None], float]: @@ -728,7 +732,7 @@ def async_pause_setup(hass: core.HomeAssistant, phase: SetupPhases) -> Generator ) -@singleton.singleton(DATA_SETUP_TIME) +@singleton.singleton(_DATA_SETUP_TIME) def _setup_times( hass: core.HomeAssistant, ) -> defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]]: @@ -783,7 +787,7 @@ def async_start_setup( # platforms, but we only care about the longest time. group_setup_times[phase] = max(group_setup_times[phase], time_taken) if group is None: - _LOGGER.debug( + _LOGGER.info( "Setup of domain %s took %.2f seconds", integration, time_taken ) elif _LOGGER.isEnabledFor(logging.DEBUG): @@ -828,3 +832,11 @@ def async_get_domain_setup_times( ) -> Mapping[str | None, dict[SetupPhases, float]]: """Return timing data for each integration.""" return _setup_times(hass).get(domain, {}) + + +async def async_wait_component(hass: HomeAssistant, domain: str) -> bool: + """Wait until a component is set up if pending, then return if it is set up.""" + setup_done = hass.data.get(_DATA_SETUP_DONE, {}) + if setup_future := setup_done.get(domain): + await setup_future + return domain in hass.config.components diff --git a/homeassistant/strings.json b/homeassistant/strings.json index dd3caa1ff51..6175f587318 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -118,24 +118,37 @@ }, "state": { "active": "Active", + "auto": "Auto", "charging": "Charging", "closed": "Closed", + "closing": "Closing", "connected": "Connected", "disabled": "Disabled", "discharging": "Discharging", "disconnected": "Disconnected", "enabled": "Enabled", + "error": "Error", + "fault": "Fault", + "high": "High", "home": "Home", "idle": "Idle", "locked": "Locked", + "low": "Low", + "manual": "Manual", + "medium": "Medium", "no": "No", + "normal": "Normal", "not_home": "Away", "off": "Off", "on": "On", "open": "Open", + "opening": "Opening", "paused": "Paused", "standby": "Standby", + "stopped": "Stopped", "unlocked": "Unlocked", + "very_high": "Very high", + "very_low": "Very low", "yes": "Yes" }, "time": { diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index eb898e4b544..ce30e9d6414 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -390,7 +390,9 @@ def parse_time_expression(parameter: Any, min_value: int, max_value: int) -> lis elif isinstance(parameter, str): if parameter.startswith("/"): parameter = int(parameter[1:]) - res = [x for x in range(min_value, max_value + 1) if x % parameter == 0] + res = list( + range(min_value + (-min_value % parameter), max_value + 1, parameter) + ) else: res = [int(parameter)] diff --git a/homeassistant/util/read_only_dict.py b/homeassistant/util/read_only_dict.py index 02befa78f60..3e4710cf220 100644 --- a/homeassistant/util/read_only_dict.py +++ b/homeassistant/util/read_only_dict.py @@ -1,7 +1,7 @@ """Read only dictionary.""" from copy import deepcopy -from typing import Any +from typing import Any, final def _readonly(*args: Any, **kwargs: Any) -> Any: @@ -9,6 +9,7 @@ def _readonly(*args: Any, **kwargs: Any) -> Any: raise RuntimeError("Cannot modify ReadOnlyDict") +@final # Final to allow direct checking of the type instead of using isinstance class ReadOnlyDict[_KT, _VT](dict[_KT, _VT]): """Read only version of dict that is compatible with dict types.""" diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index a22fd0c8fb4..4e26a126f39 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -82,10 +82,10 @@ def _client_context_no_verify(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: return sslcontext -@cache -def _client_context( +def _create_client_context( ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, ) -> ssl.SSLContext: + """Return an independent SSL context for making requests.""" # Reuse environment variable definition from requests, since it's already a # requirement. If the environment variable has no value, fall back to using # certs from certifi package. @@ -100,6 +100,14 @@ def _client_context( return sslcontext +@cache +def _client_context( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: + # Cached version of _create_client_context + return _create_client_context(ssl_cipher_list) + + # Create this only once and reuse it _DEFAULT_SSL_CONTEXT = _client_context(SSLCipherList.PYTHON_DEFAULT) _DEFAULT_NO_VERIFY_SSL_CONTEXT = _client_context_no_verify(SSLCipherList.PYTHON_DEFAULT) @@ -139,6 +147,14 @@ def client_context( return _SSL_CONTEXTS.get(ssl_cipher_list, _DEFAULT_SSL_CONTEXT) +def create_client_context( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: + """Return an independent SSL context for making requests.""" + # This explicitly uses the non-cached version to create a client context + return _create_client_context(ssl_cipher_list) + + def create_no_verify_ssl_context( ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, ) -> ssl.SSLContext: diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index f2619c5dd61..f559512c1a7 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -705,10 +705,13 @@ class VolumeFlowRateConverter(BaseUnitConverter): # Units in terms of m³/h _UNIT_CONVERSION: dict[str | None, float] = { UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR: 1, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_SECOND: 1 / _HRS_TO_SECS, UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE: 1 / (_HRS_TO_MINUTES * _CUBIC_FOOT_TO_CUBIC_METER), + UnitOfVolumeFlowRate.LITERS_PER_HOUR: 1 / _L_TO_CUBIC_METER, UnitOfVolumeFlowRate.LITERS_PER_MINUTE: 1 / (_HRS_TO_MINUTES * _L_TO_CUBIC_METER), + UnitOfVolumeFlowRate.LITERS_PER_SECOND: 1 / (_HRS_TO_SECS * _L_TO_CUBIC_METER), UnitOfVolumeFlowRate.GALLONS_PER_MINUTE: 1 / (_HRS_TO_MINUTES * _GALLON_TO_CUBIC_METER), UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND: 1 @@ -717,7 +720,10 @@ class VolumeFlowRateConverter(BaseUnitConverter): VALID_UNITS = { UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_SECOND, + UnitOfVolumeFlowRate.LITERS_PER_HOUR, UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + UnitOfVolumeFlowRate.LITERS_PER_SECOND, UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND, } diff --git a/mypy.ini b/mypy.ini index 9831a183ec4..94c47d7ce22 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2666,6 +2666,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.kulersky.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lacrosse.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3376,6 +3386,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ntfy.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.number.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3396,6 +3416,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ohme.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.onboarding.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3586,6 +3616,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.pegel_online.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.persistent_notification.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -4346,6 +4386,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.smtp.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.snooz.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index ca7777da959..3e18aacaa93 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -597,6 +597,16 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), ], ), + ClassTypeHintMatch( + base_class="ConfigSubentryFlow", + matches=[ + TypeHintMatch( + function_name="async_step_*", + arg_types={}, + return_type="SubentryFlowResult", + ), + ], + ), ], } # Overriding properties and functions are normally checked by mypy, and will only @@ -1364,7 +1374,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), TypeHintMatch( function_name="location_accuracy", - return_type="int", + return_type="float", ), TypeHintMatch( function_name="location_name", diff --git a/pyproject.toml b/pyproject.toml index c1a6ffbadff..50cc169cf10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,136 +3,136 @@ requires = ["setuptools==78.1.1"] build-backend = "setuptools.build_meta" [project] -name = "homeassistant" -version = "2025.4.4" -license = "Apache-2.0" +name = "homeassistant" +version = "2025.5.0" +license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." -readme = "README.rst" -authors = [ - {name = "The Home Assistant Authors", email = "hello@home-assistant.io"} +readme = "README.rst" +authors = [ + { name = "The Home Assistant Authors", email = "hello@home-assistant.io" }, ] -keywords = ["home", "automation"] +keywords = ["home", "automation"] classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: End Users/Desktop", - "Intended Audience :: Developers", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3.13", - "Topic :: Home Automation", + "Development Status :: 5 - Production/Stable", + "Intended Audience :: End Users/Desktop", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.13", + "Topic :: Home Automation", ] -requires-python = ">=3.13.0" -dependencies = [ - "aiodns==3.2.0", - # Integrations may depend on hassio integration without listing it to - # change behavior based on presence of supervisor. Deprecated with #127228 - # Lib can be removed with 2025.11 - "aiohasupervisor==0.3.0", - "aiohttp==3.11.16", - "aiohttp_cors==0.7.0", - "aiohttp-fast-zlib==0.2.3", - "aiohttp-asyncmdnsresolver==0.1.1", - "aiozoneinfo==0.2.3", - "annotatedyaml==0.4.5", - "astral==2.2", - "async-interrupt==1.2.2", - "attrs==25.1.0", - "atomicwrites-homeassistant==1.4.1", - "audioop-lts==0.2.1", - "awesomeversion==24.6.0", - "bcrypt==4.2.0", - "certifi>=2021.5.30", - "ciso8601==2.3.2", - "cronsim==2.6", - "fnv-hash-fast==1.4.0", - # ha-ffmpeg is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->tts->ffmpeg. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "ha-ffmpeg==3.2.2", - # hass-nabucasa is imported by helpers which don't depend on the cloud - # integration - "hass-nabucasa==0.94.0", - # hassil is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->conversation->hassil. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "hassil==2.2.3", - # When bumping httpx, please check the version pins of - # httpcore, anyio, and h11 in gen_requirements_all - "httpx==0.28.1", - "home-assistant-bluetooth==1.13.1", - # home_assistant_intents is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->conversation->home_assistant_intents. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "home-assistant-intents==2025.3.28", - "ifaddr==0.2.0", - "Jinja2==3.1.6", - "lru-dict==1.3.0", - # mutagen is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->tts->mutagen. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "mutagen==1.47.0", - # numpy is indirectly imported from onboarding via the import chain - # onboarding->cloud->alexa->camera->stream->numpy. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "numpy==2.2.2", - "PyJWT==2.10.1", - # PyJWT has loose dependency. We want the latest one. - "cryptography==44.0.1", - "Pillow==11.1.0", - "propcache==0.3.0", - "pyOpenSSL==25.0.0", - "orjson==3.10.16", - "packaging>=23.1", - "psutil-home-assistant==0.0.1", - # pymicro_vad is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->pymicro_vad. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "pymicro-vad==1.0.1", - # pyspeex-noise is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->pyspeex_noise. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "pyspeex-noise==1.0.2", - "python-slugify==8.0.4", - # PyTurboJPEG is indirectly imported from onboarding via the import chain - # onboarding->cloud->camera->pyturbojpeg. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "PyTurboJPEG==1.7.5", - "PyYAML==6.0.2", - "requests==2.32.3", - "securetar==2025.2.1", - "SQLAlchemy==2.0.39", - "standard-aifc==3.13.0", - "standard-telnetlib==3.13.0", - "typing-extensions>=4.13.0,<5.0", - "ulid-transform==1.4.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.6.10", - "voluptuous==0.15.2", - "voluptuous-serialize==2.6.0", - "voluptuous-openapi==0.0.6", - "yarl==1.18.3", - "webrtc-models==0.3.0", - "zeroconf==0.146.0" +requires-python = ">=3.13.2" +dependencies = [ + "aiodns==3.3.0", + # Integrations may depend on hassio integration without listing it to + # change behavior based on presence of supervisor. Deprecated with #127228 + # Lib can be removed with 2025.11 + "aiohasupervisor==0.3.1", + "aiohttp==3.11.18", + "aiohttp_cors==0.7.0", + "aiohttp-fast-zlib==0.2.3", + "aiohttp-asyncmdnsresolver==0.1.1", + "aiozoneinfo==0.2.3", + "annotatedyaml==0.4.5", + "astral==2.2", + "async-interrupt==1.2.2", + "attrs==25.1.0", + "atomicwrites-homeassistant==1.4.1", + "audioop-lts==0.2.1", + "awesomeversion==24.6.0", + "bcrypt==4.2.0", + "certifi>=2021.5.30", + "ciso8601==2.3.2", + "cronsim==2.6", + "fnv-hash-fast==1.5.0", + # ha-ffmpeg is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->tts->ffmpeg. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "ha-ffmpeg==3.2.2", + # hass-nabucasa is imported by helpers which don't depend on the cloud + # integration + "hass-nabucasa==0.96.0", + # hassil is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->conversation->hassil. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "hassil==2.2.3", + # When bumping httpx, please check the version pins of + # httpcore, anyio, and h11 in gen_requirements_all + "httpx==0.28.1", + "home-assistant-bluetooth==1.13.1", + # home_assistant_intents is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->conversation->home_assistant_intents. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "home-assistant-intents==2025.5.7", + "ifaddr==0.2.0", + "Jinja2==3.1.6", + "lru-dict==1.3.0", + # mutagen is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->tts->mutagen. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "mutagen==1.47.0", + # numpy is indirectly imported from onboarding via the import chain + # onboarding->cloud->alexa->camera->stream->numpy. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "numpy==2.2.2", + "PyJWT==2.10.1", + # PyJWT has loose dependency. We want the latest one. + "cryptography==44.0.1", + "Pillow==11.2.1", + "propcache==0.3.1", + "pyOpenSSL==25.0.0", + "orjson==3.10.18", + "packaging>=23.1", + "psutil-home-assistant==0.0.1", + # pymicro_vad is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->pymicro_vad. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "pymicro-vad==1.0.1", + # pyspeex-noise is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->pyspeex_noise. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "pyspeex-noise==1.0.2", + "python-slugify==8.0.4", + # PyTurboJPEG is indirectly imported from onboarding via the import chain + # onboarding->cloud->camera->pyturbojpeg. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "PyTurboJPEG==1.7.5", + "PyYAML==6.0.2", + "requests==2.32.3", + "securetar==2025.2.1", + "SQLAlchemy==2.0.40", + "standard-aifc==3.13.0", + "standard-telnetlib==3.13.0", + "typing-extensions>=4.13.0,<5.0", + "ulid-transform==1.4.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.7.1", + "voluptuous==0.15.2", + "voluptuous-serialize==2.6.0", + "voluptuous-openapi==0.0.7", + "yarl==1.20.0", + "webrtc-models==0.3.0", + "zeroconf==0.147.0", ] [project.urls] -"Homepage" = "https://www.home-assistant.io/" +"Homepage" = "https://www.home-assistant.io/" "Source Code" = "https://github.com/home-assistant/core" "Bug Reports" = "https://github.com/home-assistant/core/issues" -"Docs: Dev" = "https://developers.home-assistant.io/" -"Discord" = "https://www.home-assistant.io/join-chat/" -"Forum" = "https://community.home-assistant.io/" +"Docs: Dev" = "https://developers.home-assistant.io/" +"Discord" = "https://www.home-assistant.io/join-chat/" +"Forum" = "https://community.home-assistant.io/" [project.scripts] hass = "homeassistant.__main__:main" @@ -159,30 +159,28 @@ init-hook = """\ ) \ """ load-plugins = [ - "pylint.extensions.code_style", - "pylint.extensions.typing", - "hass_decorator", - "hass_enforce_class_module", - "hass_enforce_sorted_platforms", - "hass_enforce_super_call", - "hass_enforce_type_hints", - "hass_inheritance", - "hass_imports", - "hass_logger", - "pylint_per_file_ignores", + "pylint.extensions.code_style", + "pylint.extensions.typing", + "hass_decorator", + "hass_enforce_class_module", + "hass_enforce_sorted_platforms", + "hass_enforce_super_call", + "hass_enforce_type_hints", + "hass_inheritance", + "hass_imports", + "hass_logger", + "pylint_per_file_ignores", ] persistent = false extension-pkg-allow-list = [ - "av.audio.stream", - "av.logging", - "av.stream", - "ciso8601", - "orjson", - "cv2", -] -fail-on = [ - "I", + "av.audio.stream", + "av.logging", + "av.stream", + "ciso8601", + "orjson", + "cv2", ] +fail-on = ["I"] [tool.pylint.BASIC] class-const-naming-style = "any" @@ -207,257 +205,257 @@ class-const-naming-style = "any" # consider-using-namedtuple-or-dataclass - too opinionated # consider-using-assignment-expr - decision to use := better left to devs disable = [ - "format", - "abstract-method", - "cyclic-import", - "duplicate-code", - "inconsistent-return-statements", - "locally-disabled", - "not-context-manager", - "too-few-public-methods", - "too-many-ancestors", - "too-many-arguments", - "too-many-instance-attributes", - "too-many-lines", - "too-many-locals", - "too-many-public-methods", - "too-many-boolean-expressions", - "too-many-positional-arguments", - "wrong-import-order", - "consider-using-namedtuple-or-dataclass", - "consider-using-assignment-expr", - "possibly-used-before-assignment", + "format", + "abstract-method", + "cyclic-import", + "duplicate-code", + "inconsistent-return-statements", + "locally-disabled", + "not-context-manager", + "too-few-public-methods", + "too-many-ancestors", + "too-many-arguments", + "too-many-instance-attributes", + "too-many-lines", + "too-many-locals", + "too-many-public-methods", + "too-many-boolean-expressions", + "too-many-positional-arguments", + "wrong-import-order", + "consider-using-namedtuple-or-dataclass", + "consider-using-assignment-expr", + "possibly-used-before-assignment", - # Handled by ruff - # Ref: - "await-outside-async", # PLE1142 - "bad-str-strip-call", # PLE1310 - "bad-string-format-type", # PLE1307 - "bidirectional-unicode", # PLE2502 - "continue-in-finally", # PLE0116 - "duplicate-bases", # PLE0241 - "misplaced-bare-raise", # PLE0704 - "format-needs-mapping", # F502 - "function-redefined", # F811 - # Needed because ruff does not understand type of __all__ generated by a function - # "invalid-all-format", # PLE0605 - "invalid-all-object", # PLE0604 - "invalid-character-backspace", # PLE2510 - "invalid-character-esc", # PLE2513 - "invalid-character-nul", # PLE2514 - "invalid-character-sub", # PLE2512 - "invalid-character-zero-width-space", # PLE2515 - "logging-too-few-args", # PLE1206 - "logging-too-many-args", # PLE1205 - "missing-format-string-key", # F524 - "mixed-format-string", # F506 - "no-method-argument", # N805 - "no-self-argument", # N805 - "nonexistent-operator", # B002 - "nonlocal-without-binding", # PLE0117 - "not-in-loop", # F701, F702 - "notimplemented-raised", # F901 - "return-in-init", # PLE0101 - "return-outside-function", # F706 - "syntax-error", # E999 - "too-few-format-args", # F524 - "too-many-format-args", # F522 - "too-many-star-expressions", # F622 - "truncated-format-string", # F501 - "undefined-all-variable", # F822 - "undefined-variable", # F821 - "used-prior-global-declaration", # PLE0118 - "yield-inside-async-function", # PLE1700 - "yield-outside-function", # F704 - "anomalous-backslash-in-string", # W605 - "assert-on-string-literal", # PLW0129 - "assert-on-tuple", # F631 - "bad-format-string", # W1302, F - "bad-format-string-key", # W1300, F - "bare-except", # E722 - "binary-op-exception", # PLW0711 - "cell-var-from-loop", # B023 - # "dangerous-default-value", # B006, ruff catches new occurrences, needs more work - "duplicate-except", # B014 - "duplicate-key", # F601 - "duplicate-string-formatting-argument", # F - "duplicate-value", # F - "eval-used", # S307 - "exec-used", # S102 - "expression-not-assigned", # B018 - "f-string-without-interpolation", # F541 - "forgotten-debug-statement", # T100 - "format-string-without-interpolation", # F - # "global-statement", # PLW0603, ruff catches new occurrences, needs more work - "global-variable-not-assigned", # PLW0602 - "implicit-str-concat", # ISC001 - "import-self", # PLW0406 - "inconsistent-quotes", # Q000 - "invalid-envvar-default", # PLW1508 - "keyword-arg-before-vararg", # B026 - "logging-format-interpolation", # G - "logging-fstring-interpolation", # G - "logging-not-lazy", # G - "misplaced-future", # F404 - "named-expr-without-context", # PLW0131 - "nested-min-max", # PLW3301 - "pointless-statement", # B018 - "raise-missing-from", # B904 - "redefined-builtin", # A001 - "try-except-raise", # TRY302 - "unused-argument", # ARG001, we don't use it - "unused-format-string-argument", #F507 - "unused-format-string-key", # F504 - "unused-import", # F401 - "unused-variable", # F841 - "useless-else-on-loop", # PLW0120 - "wildcard-import", # F403 - "bad-classmethod-argument", # N804 - "consider-iterating-dictionary", # SIM118 - "empty-docstring", # D419 - "invalid-name", # N815 - "line-too-long", # E501, disabled globally - "missing-class-docstring", # D101 - "missing-final-newline", # W292 - "missing-function-docstring", # D103 - "missing-module-docstring", # D100 - "multiple-imports", #E401 - "singleton-comparison", # E711, E712 - "subprocess-run-check", # PLW1510 - "superfluous-parens", # UP034 - "ungrouped-imports", # I001 - "unidiomatic-typecheck", # E721 - "unnecessary-direct-lambda-call", # PLC3002 - "unnecessary-lambda-assignment", # PLC3001 - "unnecessary-pass", # PIE790 - "unneeded-not", # SIM208 - "useless-import-alias", # PLC0414 - "wrong-import-order", # I001 - "wrong-import-position", # E402 - "comparison-of-constants", # PLR0133 - "comparison-with-itself", # PLR0124 - "consider-alternative-union-syntax", # UP007 - "consider-merging-isinstance", # PLR1701 - "consider-using-alias", # UP006 - "consider-using-dict-comprehension", # C402 - "consider-using-generator", # C417 - "consider-using-get", # SIM401 - "consider-using-set-comprehension", # C401 - "consider-using-sys-exit", # PLR1722 - "consider-using-ternary", # SIM108 - "literal-comparison", # F632 - "property-with-parameters", # PLR0206 - "super-with-arguments", # UP008 - "too-many-branches", # PLR0912 - "too-many-return-statements", # PLR0911 - "too-many-statements", # PLR0915 - "trailing-comma-tuple", # COM818 - "unnecessary-comprehension", # C416 - "use-a-generator", # C417 - "use-dict-literal", # C406 - "use-list-literal", # C405 - "useless-object-inheritance", # UP004 - "useless-return", # PLR1711 - "no-else-break", # RET508 - "no-else-continue", # RET507 - "no-else-raise", # RET506 - "no-else-return", # RET505 - "broad-except", # BLE001 - "protected-access", # SLF001 - "broad-exception-raised", # TRY002 - "consider-using-f-string", # PLC0209 - # "no-self-use", # PLR6301 # Optional plugin, not enabled + # Handled by ruff + # Ref: + "await-outside-async", # PLE1142 + "bad-str-strip-call", # PLE1310 + "bad-string-format-type", # PLE1307 + "bidirectional-unicode", # PLE2502 + "continue-in-finally", # PLE0116 + "duplicate-bases", # PLE0241 + "misplaced-bare-raise", # PLE0704 + "format-needs-mapping", # F502 + "function-redefined", # F811 + # Needed because ruff does not understand type of __all__ generated by a function + # "invalid-all-format", # PLE0605 + "invalid-all-object", # PLE0604 + "invalid-character-backspace", # PLE2510 + "invalid-character-esc", # PLE2513 + "invalid-character-nul", # PLE2514 + "invalid-character-sub", # PLE2512 + "invalid-character-zero-width-space", # PLE2515 + "logging-too-few-args", # PLE1206 + "logging-too-many-args", # PLE1205 + "missing-format-string-key", # F524 + "mixed-format-string", # F506 + "no-method-argument", # N805 + "no-self-argument", # N805 + "nonexistent-operator", # B002 + "nonlocal-without-binding", # PLE0117 + "not-in-loop", # F701, F702 + "notimplemented-raised", # F901 + "return-in-init", # PLE0101 + "return-outside-function", # F706 + "syntax-error", # E999 + "too-few-format-args", # F524 + "too-many-format-args", # F522 + "too-many-star-expressions", # F622 + "truncated-format-string", # F501 + "undefined-all-variable", # F822 + "undefined-variable", # F821 + "used-prior-global-declaration", # PLE0118 + "yield-inside-async-function", # PLE1700 + "yield-outside-function", # F704 + "anomalous-backslash-in-string", # W605 + "assert-on-string-literal", # PLW0129 + "assert-on-tuple", # F631 + "bad-format-string", # W1302, F + "bad-format-string-key", # W1300, F + "bare-except", # E722 + "binary-op-exception", # PLW0711 + "cell-var-from-loop", # B023 + # "dangerous-default-value", # B006, ruff catches new occurrences, needs more work + "duplicate-except", # B014 + "duplicate-key", # F601 + "duplicate-string-formatting-argument", # F + "duplicate-value", # F + "eval-used", # S307 + "exec-used", # S102 + "expression-not-assigned", # B018 + "f-string-without-interpolation", # F541 + "forgotten-debug-statement", # T100 + "format-string-without-interpolation", # F + # "global-statement", # PLW0603, ruff catches new occurrences, needs more work + "global-variable-not-assigned", # PLW0602 + "implicit-str-concat", # ISC001 + "import-self", # PLW0406 + "inconsistent-quotes", # Q000 + "invalid-envvar-default", # PLW1508 + "keyword-arg-before-vararg", # B026 + "logging-format-interpolation", # G + "logging-fstring-interpolation", # G + "logging-not-lazy", # G + "misplaced-future", # F404 + "named-expr-without-context", # PLW0131 + "nested-min-max", # PLW3301 + "pointless-statement", # B018 + "raise-missing-from", # B904 + "redefined-builtin", # A001 + "try-except-raise", # TRY302 + "unused-argument", # ARG001, we don't use it + "unused-format-string-argument", #F507 + "unused-format-string-key", # F504 + "unused-import", # F401 + "unused-variable", # F841 + "useless-else-on-loop", # PLW0120 + "wildcard-import", # F403 + "bad-classmethod-argument", # N804 + "consider-iterating-dictionary", # SIM118 + "empty-docstring", # D419 + "invalid-name", # N815 + "line-too-long", # E501, disabled globally + "missing-class-docstring", # D101 + "missing-final-newline", # W292 + "missing-function-docstring", # D103 + "missing-module-docstring", # D100 + "multiple-imports", #E401 + "singleton-comparison", # E711, E712 + "subprocess-run-check", # PLW1510 + "superfluous-parens", # UP034 + "ungrouped-imports", # I001 + "unidiomatic-typecheck", # E721 + "unnecessary-direct-lambda-call", # PLC3002 + "unnecessary-lambda-assignment", # PLC3001 + "unnecessary-pass", # PIE790 + "unneeded-not", # SIM208 + "useless-import-alias", # PLC0414 + "wrong-import-order", # I001 + "wrong-import-position", # E402 + "comparison-of-constants", # PLR0133 + "comparison-with-itself", # PLR0124 + "consider-alternative-union-syntax", # UP007 + "consider-merging-isinstance", # PLR1701 + "consider-using-alias", # UP006 + "consider-using-dict-comprehension", # C402 + "consider-using-generator", # C417 + "consider-using-get", # SIM401 + "consider-using-set-comprehension", # C401 + "consider-using-sys-exit", # PLR1722 + "consider-using-ternary", # SIM108 + "literal-comparison", # F632 + "property-with-parameters", # PLR0206 + "super-with-arguments", # UP008 + "too-many-branches", # PLR0912 + "too-many-return-statements", # PLR0911 + "too-many-statements", # PLR0915 + "trailing-comma-tuple", # COM818 + "unnecessary-comprehension", # C416 + "use-a-generator", # C417 + "use-dict-literal", # C406 + "use-list-literal", # C405 + "useless-object-inheritance", # UP004 + "useless-return", # PLR1711 + "no-else-break", # RET508 + "no-else-continue", # RET507 + "no-else-raise", # RET506 + "no-else-return", # RET505 + "broad-except", # BLE001 + "protected-access", # SLF001 + "broad-exception-raised", # TRY002 + "consider-using-f-string", # PLC0209 + # "no-self-use", # PLR6301 # Optional plugin, not enabled - # Handled by mypy - # Ref: - "abstract-class-instantiated", - "arguments-differ", - "assigning-non-slot", - "assignment-from-no-return", - "assignment-from-none", - "bad-exception-cause", - "bad-format-character", - "bad-reversed-sequence", - "bad-super-call", - "bad-thread-instantiation", - "catching-non-exception", - "comparison-with-callable", - "deprecated-class", - "dict-iter-missing-items", - "format-combined-specification", - "global-variable-undefined", - "import-error", - "inconsistent-mro", - "inherit-non-class", - "init-is-generator", - "invalid-class-object", - "invalid-enum-extension", - "invalid-envvar-value", - "invalid-format-returned", - "invalid-hash-returned", - "invalid-metaclass", - "invalid-overridden-method", - "invalid-repr-returned", - "invalid-sequence-index", - "invalid-slice-index", - "invalid-slots-object", - "invalid-slots", - "invalid-star-assignment-target", - "invalid-str-returned", - "invalid-unary-operand-type", - "invalid-unicode-codec", - "isinstance-second-argument-not-valid-type", - "method-hidden", - "misplaced-format-function", - "missing-format-argument-key", - "missing-format-attribute", - "missing-kwoa", - "no-member", - "no-value-for-parameter", - "non-iterator-returned", - "non-str-assignment-to-dunder-name", - "nonlocal-and-global", - "not-a-mapping", - "not-an-iterable", - "not-async-context-manager", - "not-callable", - "not-context-manager", - "overridden-final-method", - "raising-bad-type", - "raising-non-exception", - "redundant-keyword-arg", - "relative-beyond-top-level", - "self-cls-assignment", - "signature-differs", - "star-needs-assignment-target", - "subclassed-final-class", - "super-without-brackets", - "too-many-function-args", - "typevar-double-variance", - "typevar-name-mismatch", - "unbalanced-dict-unpacking", - "unbalanced-tuple-unpacking", - "unexpected-keyword-arg", - "unhashable-member", - "unpacking-non-sequence", - "unsubscriptable-object", - "unsupported-assignment-operation", - "unsupported-binary-operation", - "unsupported-delete-operation", - "unsupported-membership-test", - "used-before-assignment", - "using-final-decorator-in-unsupported-version", - "wrong-exception-operation", + # Handled by mypy + # Ref: + "abstract-class-instantiated", + "arguments-differ", + "assigning-non-slot", + "assignment-from-no-return", + "assignment-from-none", + "bad-exception-cause", + "bad-format-character", + "bad-reversed-sequence", + "bad-super-call", + "bad-thread-instantiation", + "catching-non-exception", + "comparison-with-callable", + "deprecated-class", + "dict-iter-missing-items", + "format-combined-specification", + "global-variable-undefined", + "import-error", + "inconsistent-mro", + "inherit-non-class", + "init-is-generator", + "invalid-class-object", + "invalid-enum-extension", + "invalid-envvar-value", + "invalid-format-returned", + "invalid-hash-returned", + "invalid-metaclass", + "invalid-overridden-method", + "invalid-repr-returned", + "invalid-sequence-index", + "invalid-slice-index", + "invalid-slots-object", + "invalid-slots", + "invalid-star-assignment-target", + "invalid-str-returned", + "invalid-unary-operand-type", + "invalid-unicode-codec", + "isinstance-second-argument-not-valid-type", + "method-hidden", + "misplaced-format-function", + "missing-format-argument-key", + "missing-format-attribute", + "missing-kwoa", + "no-member", + "no-value-for-parameter", + "non-iterator-returned", + "non-str-assignment-to-dunder-name", + "nonlocal-and-global", + "not-a-mapping", + "not-an-iterable", + "not-async-context-manager", + "not-callable", + "not-context-manager", + "overridden-final-method", + "raising-bad-type", + "raising-non-exception", + "redundant-keyword-arg", + "relative-beyond-top-level", + "self-cls-assignment", + "signature-differs", + "star-needs-assignment-target", + "subclassed-final-class", + "super-without-brackets", + "too-many-function-args", + "typevar-double-variance", + "typevar-name-mismatch", + "unbalanced-dict-unpacking", + "unbalanced-tuple-unpacking", + "unexpected-keyword-arg", + "unhashable-member", + "unpacking-non-sequence", + "unsubscriptable-object", + "unsupported-assignment-operation", + "unsupported-binary-operation", + "unsupported-delete-operation", + "unsupported-membership-test", + "used-before-assignment", + "using-final-decorator-in-unsupported-version", + "wrong-exception-operation", ] enable = [ - #"useless-suppression", # temporarily every now and then to clean them up - "use-symbolic-message-instead", + #"useless-suppression", # temporarily every now and then to clean them up + "use-symbolic-message-instead", ] per-file-ignores = [ - # redefined-outer-name: Tests reference fixtures in the test function - # use-implicit-booleaness-not-comparison: Tests need to validate that a list - # or a dict is returned - "/tests/:redefined-outer-name,use-implicit-booleaness-not-comparison", + # redefined-outer-name: Tests reference fixtures in the test function + # use-implicit-booleaness-not-comparison: Tests need to validate that a list + # or a dict is returned + "/tests/:redefined-outer-name,use-implicit-booleaness-not-comparison", ] [tool.pylint.REPORTS] @@ -465,7 +463,7 @@ score = false [tool.pylint.TYPECHECK] ignored-classes = [ - "_CountingAttr", # for attrs + "_CountingAttr", # for attrs ] mixin-class-rgx = ".*[Mm]ix[Ii]n" @@ -474,9 +472,9 @@ expected-line-ending-format = "LF" [tool.pylint.EXCEPTIONS] overgeneral-exceptions = [ - "builtins.BaseException", - "builtins.Exception", - # "homeassistant.exceptions.HomeAssistantError", # too many issues + "builtins.BaseException", + "builtins.Exception", + # "homeassistant.exceptions.HomeAssistantError", # too many issues ] [tool.pylint.TYPING] @@ -486,239 +484,200 @@ runtime-typing = false max-line-length-suggestions = 72 [tool.pytest.ini_options] -testpaths = [ - "tests", -] -norecursedirs = [ - ".git", - "testing_config", -] +testpaths = ["tests"] +norecursedirs = [".git", "testing_config"] log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s" log_date_format = "%Y-%m-%d %H:%M:%S" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [ - "error::sqlalchemy.exc.SAWarning", + "error::sqlalchemy.exc.SAWarning", - # -- HomeAssistant - aiohttp - # Overwrite web.Application to pass a custom default argument to _make_request - "ignore:Inheritance class HomeAssistantApplication from web.Application is discouraged:DeprecationWarning", - # Hass wraps `ClientSession.close` to emit a warning if the session is closed accidentally - "ignore:Setting custom ClientSession.close attribute is discouraged:DeprecationWarning:homeassistant.helpers.aiohttp_client", - # Modify app state for testing - "ignore:Changing state of started or joined application is deprecated:DeprecationWarning:tests.components.http.test_ban", + # -- HomeAssistant - aiohttp + # Overwrite web.Application to pass a custom default argument to _make_request + "ignore:Inheritance class HomeAssistantApplication from web.Application is discouraged:DeprecationWarning", + # Hass wraps `ClientSession.close` to emit a warning if the session is closed accidentally + "ignore:Setting custom ClientSession.close attribute is discouraged:DeprecationWarning:homeassistant.helpers.aiohttp_client", + # Modify app state for testing + "ignore:Changing state of started or joined application is deprecated:DeprecationWarning:tests.components.http.test_ban", - # -- Tests - # Ignore custom pytest marks - "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", - "ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic", + # -- Tests + # Ignore custom pytest marks + "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", + "ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic", - # -- design choice 3rd party - # https://github.com/gwww/elkm1/blob/2.2.10/elkm1_lib/util.py#L8-L19 - "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", - # https://github.com/allenporter/ical/pull/215 - # https://github.com/allenporter/ical/blob/8.2.0/ical/util.py#L21-L23 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:ical.util", - # https://github.com/bachya/regenmaschine/blob/2024.03.0/regenmaschine/client.py#L52 - "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", + # -- DeprecationWarning already fixed in our codebase + # https://github.com/kurtmckee/feedparser/pull/389 - 6.0.11 + "ignore:.*a temporary mapping .* from `updated_parsed` to `published_parsed` if `updated_parsed` doesn't exist:DeprecationWarning:feedparser.util", - # -- Setuptools DeprecationWarnings - # https://github.com/googleapis/google-cloud-python/issues/11184 - # https://github.com/zopefoundation/meta/issues/194 - # https://github.com/Azure/azure-sdk-for-python - "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('azure'|'google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources", + # -- design choice 3rd party + # https://github.com/gwww/elkm1/blob/2.2.11/elkm1_lib/util.py#L8-L19 + "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", + # https://github.com/bachya/regenmaschine/blob/2024.03.0/regenmaschine/client.py#L52 + "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", - # -- tracked upstream / open PRs - # - pyOpenSSL v24.2.1 - # https://github.com/certbot/certbot/issues/9828 - v2.11.0 - # https://github.com/certbot/certbot/issues/9992 - "ignore:X509Extension support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", - "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", - "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:josepy.util", - # - other - # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.3 - # https://github.com/foxel/python_ndms2_client/pull/8 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", + # -- Setuptools DeprecationWarnings + # https://github.com/googleapis/google-cloud-python/issues/11184 + # https://github.com/zopefoundation/meta/issues/194 + # https://github.com/Azure/azure-sdk-for-python + "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('azure'|'google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources", - # -- fixed, waiting for release / update - # https://github.com/bachya/aiopurpleair/pull/200 - >=2023.10.0 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiopurpleair.helpers.validators", - # https://bugs.launchpad.net/beautifulsoup/+bug/2076897 - >4.12.3 - "ignore:The 'strip_cdata' option of HTMLParser\\(\\) has never done anything and will eventually be removed:DeprecationWarning:bs4.builder._lxml", - # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0 - "ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", - # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:datadog.util.compat", - # https://github.com/fwestenberg/devialet/pull/6 - >1.4.5 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:devialet.devialet_api", - # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2", - # https://github.com/influxdata/influxdb-client-python/issues/603 >=1.45.0 - # https://github.com/influxdata/influxdb-client-python/pull/652 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", - # https://github.com/majuss/lupupy/pull/15 - >0.3.2 - "ignore:\"is not\" with 'str' literal. Did you mean \"!=\"?:SyntaxWarning:.*lupupy.devices.alarm", - # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", - # https://github.com/eclipse/paho.mqtt.python/issues/653 - >=2.0.0 - # https://github.com/eclipse/paho.mqtt.python/pull/665 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:paho.mqtt.client", - # https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0 - "ignore::DeprecationWarning:holidays", - # https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", - # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 - "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", + # -- tracked upstream / open PRs + # https://github.com/hacf-fr/meteofrance-api/pull/688 - v1.4.0 - 2025-03-26 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteofrance_api.model.forecast", - # -- fixed for Python 3.13 - # https://github.com/rhasspy/wyoming/commit/e34af30d455b6f2bb9e5cfb25fad8d276914bc54 - >=1.4.2 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:wyoming.audio", + # -- fixed, waiting for release / update + # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0 + "ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", + # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:datadog.util.compat", + # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2", + # https://github.com/influxdata/influxdb-client-python/issues/603 >=1.45.0 + # https://github.com/influxdata/influxdb-client-python/pull/652 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", + # https://github.com/majuss/lupupy/pull/15 - >0.3.2 + "ignore:\"is not\" with 'str' literal. Did you mean \"!=\"?:SyntaxWarning:.*lupupy.devices.alarm", + # https://github.com/nextcord/nextcord/pull/1095 - >=3.0.0 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", + # https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0 + "ignore::DeprecationWarning:holidays", + # https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", + # https://github.com/rytilahti/python-miio/pull/1993 - >0.6.0.dev0 + "ignore:functools.partial will be a method descriptor in future Python versions; wrap it in enum.member\\(\\) if you want to preserve the old behavior:FutureWarning:miio.miot_device", + # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 + "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", - # -- other - # Locale changes might take some time to resolve upstream - # https://github.com/Squachen/micloud/blob/v_0.6/micloud/micloud.py#L35 - v0.6 - 2022-12-08 - "ignore:'locale.getdefaultlocale' is deprecated and slated for removal in Python 3.15:DeprecationWarning:micloud.micloud", - # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 - 2023-10-09 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway", - # https://github.com/lidatong/dataclasses-json/issues/328 - # https://github.com/lidatong/dataclasses-json/pull/351 - "ignore:The 'default' argument to fields is deprecated. Use 'dump_default' instead:DeprecationWarning:dataclasses_json.mm", - # https://pypi.org/project/emulated-roku/ - v0.3.0 - 2023-12-19 - # https://github.com/martonperei/emulated_roku - "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", - # https://github.com/w1ll1am23/pyeconet/blob/v0.1.23/src/pyeconet/api.py#L38 - v0.1.23 - 2024-10-08 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api", - # https://github.com/thecynic/pylutron - v0.2.16 - 2024-10-22 - "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", - # https://github.com/pschmitt/pynuki/blob/1.6.3/pynuki/utils.py#L21 - v1.6.3 - 2024-02-24 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pynuki.utils", - # https://github.com/lextudio/pysnmp/blob/v7.1.10/pysnmp/smi/compiler.py#L23-L31 - v7.1.10 - 2024-11-04 - "ignore:smiV1Relaxed is deprecated. Please use smi_v1_relaxed instead:DeprecationWarning:pysnmp.smi.compiler", - "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysmi.reader.url", # wrong stacklevel - # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 - "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", - # Wrong stacklevel - # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 fixed in >4.12.3 - "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:html.parser", - # New in aiohttp - v3.9.0 - "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)", - # - SyntaxWarnings - # https://pypi.org/project/aprslib/ - v0.7.2 - 2022-07-10 - "ignore:invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common", - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aprslib.parsing.common", - # https://pypi.org/project/panasonic-viera/ - v0.4.2 - 2024-04-24 - # https://github.com/florianholzapfel/panasonic-viera/blob/0.4.2/panasonic_viera/__init__.py#L789 - "ignore:invalid escape sequence:SyntaxWarning:.*panasonic_viera", - # https://pypi.org/project/pyblackbird/ - v0.6 - 2023-03-15 - # https://github.com/koolsb/pyblackbird/pull/9 -> closed - "ignore:invalid escape sequence:SyntaxWarning:.*pyblackbird", - # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 - "ignore:invalid escape sequence:SyntaxWarning:.*pyws66i", - # https://pypi.org/project/sanix/ - v1.0.6 - 2024-05-01 - # https://github.com/tomaszsluszniak/sanix_py/blob/v1.0.6/sanix/__init__.py#L42 - "ignore:invalid escape sequence:SyntaxWarning:.*sanix", - # https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18 - "ignore:invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", # codespell:ignore thirdparty - # - pkg_resources - # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:aiomusiccast", - # https://pypi.org/project/habitipy/ - v0.3.3 - 2024-10-28 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api", - # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pysiaalarm.data.data", - # https://pypi.org/project/pybotvac/ - v0.0.25 - 2024-04-11 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pybotvac.version", - # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pymystrom", + # -- other + # Locale changes might take some time to resolve upstream + # https://github.com/Squachen/micloud/blob/v_0.6/micloud/micloud.py#L35 - v0.6 - 2022-12-08 + "ignore:'locale.getdefaultlocale' is deprecated and slated for removal in Python 3.15:DeprecationWarning:micloud.micloud", + # https://pypi.org/project/agent-py/ - v0.0.24 - 2024-11-07 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", + # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 - 2023-10-09 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway", + # https://github.com/lidatong/dataclasses-json/issues/328 + # https://github.com/lidatong/dataclasses-json/pull/351 + "ignore:The 'default' argument to fields is deprecated. Use 'dump_default' instead:DeprecationWarning:dataclasses_json.mm", + # https://pypi.org/project/emulated-roku/ - v0.3.0 - 2023-12-19 + # https://github.com/martonperei/emulated_roku + "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", + # https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", + # https://pypi.org/project/pyeconet/ - v0.1.28 - 2025-02-15 + # https://github.com/w1ll1am23/pyeconet/blob/v0.1.28/src/pyeconet/api.py#L38 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api", + # https://github.com/thecynic/pylutron - v0.2.16 - 2024-10-22 + "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", + # https://pypi.org/project/PyMetEireann/ - v2024.11.0 - 2024-11-23 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann", + # https://github.com/pschmitt/pynuki/blob/1.6.3/pynuki/utils.py#L21 - v1.6.3 - 2024-02-24 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pynuki.utils", + # https://github.com/lextudio/pysnmp/blob/v7.1.17/pysnmp/smi/compiler.py#L23-L31 - v7.1.17 - 2025-03-19 + "ignore:smiV1Relaxed is deprecated. Please use smi_v1_relaxed instead:DeprecationWarning:pysnmp.smi.compiler", + "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysnmp.smi.compiler", + # https://github.com/Python-roborock/python-roborock/issues/305 - 2.18.0 - 2025-04-06 + "ignore:Callback API version 1 is deprecated, update to latest version:DeprecationWarning:roborock.cloud_api", + # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 + "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", + # New in aiohttp - v3.9.0 + "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)", + # - SyntaxWarnings + # https://pypi.org/project/aprslib/ - v0.7.2 - 2022-07-10 + "ignore:invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common", + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aprslib.parsing.common", + # https://pypi.org/project/panasonic-viera/ - v0.4.2 - 2024-04-24 + # https://github.com/florianholzapfel/panasonic-viera/blob/0.4.2/panasonic_viera/__init__.py#L789 + "ignore:invalid escape sequence:SyntaxWarning:.*panasonic_viera", + # https://pypi.org/project/pyblackbird/ - v0.6 - 2023-03-15 + # https://github.com/koolsb/pyblackbird/pull/9 -> closed + "ignore:invalid escape sequence:SyntaxWarning:.*pyblackbird", + # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 + "ignore:invalid escape sequence:SyntaxWarning:.*pyws66i", + # https://pypi.org/project/sanix/ - v1.0.6 - 2024-05-01 + # https://github.com/tomaszsluszniak/sanix_py/blob/v1.0.6/sanix/__init__.py#L42 + "ignore:invalid escape sequence:SyntaxWarning:.*sanix", + # https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18 + "ignore:invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", # codespell:ignore thirdparty + # - pkg_resources + # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:aiomusiccast", + # https://pypi.org/project/habitipy/ - v0.3.3 - 2024-10-28 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api", + # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pysiaalarm.data.data", + # https://pypi.org/project/pybotvac/ - v0.0.26 - 2025-02-26 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pybotvac.version", + # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pymystrom", - # -- Python 3.13 - # HomeAssistant - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.assist_pipeline.websocket_api", - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.hddtemp.sensor", - # https://pypi.org/project/nextcord/ - v2.6.0 - 2023-09-23 - # https://github.com/nextcord/nextcord/issues/1174 - # https://github.com/nextcord/nextcord/blob/v2.6.1/nextcord/player.py#L5 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nextcord.player", - # https://pypi.org/project/SpeechRecognition/ - v3.11.0 - 2024-05-05 - # https://github.com/Uberi/speech_recognition/blob/3.11.0/speech_recognition/__init__.py#L7 - "ignore:'aifc' is deprecated and slated for removal in Python 3.13:DeprecationWarning:speech_recognition", - # https://pypi.org/project/voip-utils/ - v0.2.0 - 2024-09-06 - # https://github.com/home-assistant-libs/voip-utils/blob/0.2.0/voip_utils/rtp_audio.py#L3 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:voip_utils.rtp_audio", + # -- New in Python 3.13 + # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11 + # https://github.com/kurtmckee/feedparser/issues/481 + "ignore:'count' is passed as positional argument:DeprecationWarning:feedparser.html", + # https://github.com/youknowone/python-deadlib - Backports for aifc, telnetlib + "ignore:aifc was removed in Python 3.13.*'standard-aifc':DeprecationWarning:speech_recognition", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:homeassistant.components.hddtemp.sensor", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:ndms2_client.connection", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:plumlightpad.lightpad", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:pyws66i", - # -- Python 3.13 - unmaintained projects, last release about 2+ years - # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pydub.utils", - # https://github.com/heathbar/plum-lightpad-python/issues/7 - v0.0.11 - 2018-10-16 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:plumlightpad.lightpad", - # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 - # https://github.com/ssaenger/pyws66i/blob/v1.1/pyws66i/__init__.py#L2 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pyws66i", + # -- Websockets 14.1 + # https://websockets.readthedocs.io/en/stable/howto/upgrade.html + "ignore:websockets.legacy is deprecated:DeprecationWarning:websockets.legacy", + # https://github.com/bluecurrent/HomeAssistantAPI + "ignore:websockets.client.connect is deprecated:DeprecationWarning:bluecurrent_api.websocket", + "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:bluecurrent_api.websocket", + "ignore:websockets.exceptions.InvalidStatusCode is deprecated:DeprecationWarning:bluecurrent_api.websocket", + # https://github.com/graphql-python/gql + "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:gql.transport.websockets_base", - # -- New in Python 3.13 - # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11 - # https://github.com/kurtmckee/feedparser/issues/481 - "ignore:'count' is passed as positional argument:DeprecationWarning:feedparser.html", - # https://github.com/youknowone/python-deadlib - Backports for aifc, telnetlib - "ignore:aifc was removed in Python 3.13.*'standard-aifc':DeprecationWarning:speech_recognition", - "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:homeassistant.components.hddtemp.sensor", - "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:ndms2_client.connection", - "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:plumlightpad.lightpad", - "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:pyws66i", - - # -- unmaintained projects, last release about 2+ years - # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", - # https://pypi.org/project/aiomodernforms/ - v0.1.8 - 2021-06-27 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:aiomodernforms.modernforms", - # https://pypi.org/project/alarmdecoder/ - v1.13.11 - 2021-06-01 - "ignore:invalid escape sequence:SyntaxWarning:.*alarmdecoder", - # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv", - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models", - # https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", - # https://pypi.org/project/httpsig/ - v1.3.0 - 2018-11-28 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:httpsig", - # https://pypi.org/project/influxdb/ - v5.3.2 - 2024-04-18 (archived) - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb.line_protocol", - # https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark` - # https://pypi.org/project/commentjson/ - v0.9.0 - 2020-10-05 - # https://github.com/vaidik/commentjson/issues/51 - # https://github.com/vaidik/commentjson/pull/52 - # Fixed upstream, commentjson depends on old version and seems to be unmaintained - "ignore:module '(sre_parse|sre_constants)' is deprecate:DeprecationWarning:lark.utils", - # https://pypi.org/project/lomond/ - v0.3.3 - 2018-09-21 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:lomond.session", - # https://pypi.org/project/oauth2client/ - v4.1.3 - 2018-09-07 (archived) - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:oauth2client.client", - # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 - "ignore:\"is not\" with 'int' literal. Did you mean \"!=\"?:SyntaxWarning:.*opuslib.api.decoder", - # https://pypi.org/project/passlib/ - v1.7.4 - 2020-10-08 - "ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning:passlib.utils", - # https://pypi.org/project/pilight/ - v0.1.1 - 2016-10-19 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pilight", - # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16 - "ignore:invalid escape sequence:SyntaxWarning:.*plumlightpad.plumdiscovery", - "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*plumlightpad.(lightpad|logicalload)", - # https://pypi.org/project/pure-python-adb/ - v0.3.0.dev0 - 2020-08-05 - "ignore:invalid escape sequence:SyntaxWarning:.*ppadb", - # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 - "ignore:invalid escape sequence:SyntaxWarning:.*pydub.utils", - # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19 - "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*pyiss", - # https://pypi.org/project/PyMetEireann/ - v2021.8.0 - 2021-08-16 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann", - # https://pypi.org/project/PyPasser/ - v0.0.5 - 2021-10-21 - "ignore:invalid escape sequence:SyntaxWarning:.*pypasser.utils", - # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19 - "ignore:client.loop property is deprecated:DeprecationWarning:pyqwikswitch.async_", - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:pyqwikswitch.async_", - # https://pypi.org/project/Rx/ - v3.2.0 - 2021-04-25 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:rx.internal.constants", - # https://pypi.org/project/rxv/ - v0.7.0 - 2021-10-10 - "ignore:defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead:DeprecationWarning:rxv.ssdp", + # -- unmaintained projects, last release about 2+ years + # https://pypi.org/project/aiomodernforms/ - v0.1.8 - 2021-06-27 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:aiomodernforms.modernforms", + # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv", + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models", + # https://pypi.org/project/enocean/ - v0.50.1 (installed) -> v0.60.1 - 2021-06-18 + "ignore:It looks like you're using an HTML parser to parse an XML document:UserWarning:enocean.protocol.eep", + # https://pypi.org/project/httpsig/ - v1.3.0 - 2018-11-28 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:httpsig", + # https://pypi.org/project/influxdb/ - v5.3.2 - 2024-04-18 (archived) + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb.line_protocol", + # https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark` + # https://pypi.org/project/commentjson/ - v0.9.0 - 2020-10-05 + # https://github.com/vaidik/commentjson/issues/51 + # https://github.com/vaidik/commentjson/pull/52 + # Fixed upstream, commentjson depends on old version and seems to be unmaintained + "ignore:module '(sre_parse|sre_constants)' is deprecate:DeprecationWarning:lark.utils", + # https://pypi.org/project/lomond/ - v0.3.3 - 2018-09-21 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:lomond.session", + # https://pypi.org/project/oauth2client/ - v4.1.3 - 2018-09-07 (archived) + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:oauth2client.client", + # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 + "ignore:\"is not\" with 'int' literal. Did you mean \"!=\"?:SyntaxWarning:.*opuslib.api.decoder", + # https://pypi.org/project/pilight/ - v0.1.1 - 2016-10-19 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pilight", + # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16 + "ignore:invalid escape sequence:SyntaxWarning:.*plumlightpad.plumdiscovery", + "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*plumlightpad.(lightpad|logicalload)", + # https://pypi.org/project/pure-python-adb/ - v0.3.0.dev0 - 2020-08-05 + "ignore:invalid escape sequence:SyntaxWarning:.*ppadb", + # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 + "ignore:invalid escape sequence:SyntaxWarning:.*pydub.utils", + # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19 + "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*pyiss", + # https://pypi.org/project/PyPasser/ - v0.0.5 - 2021-10-21 + "ignore:invalid escape sequence:SyntaxWarning:.*pypasser.utils", + # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19 + "ignore:client.loop property is deprecated:DeprecationWarning:pyqwikswitch.async_", + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:pyqwikswitch.async_", + # https://pypi.org/project/Rx/ - v3.2.0 - 2021-04-25 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:rx.internal.constants", + # https://pypi.org/project/rxv/ - v0.7.0 - 2021-10-10 + "ignore:defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead:DeprecationWarning:rxv.ssdp", ] [tool.coverage.run] @@ -726,16 +685,16 @@ source = ["homeassistant"] [tool.coverage.report] exclude_lines = [ - # Have to re-enable the standard pragma - "pragma: no cover", - # Don't complain about missing debug-only code: - "def __repr__", - # Don't complain if tests don't hit defensive assertion code: - "raise AssertionError", - "raise NotImplementedError", - # TYPE_CHECKING and @overload blocks are never executed during pytest run - "if TYPE_CHECKING:", - "@overload", + # Have to re-enable the standard pragma + "pragma: no cover", + # Don't complain about missing debug-only code: + "def __repr__", + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + # TYPE_CHECKING and @overload blocks are never executed during pytest run + "if TYPE_CHECKING:", + "@overload", ] [tool.ruff] @@ -743,158 +702,158 @@ required-version = ">=0.11.0" [tool.ruff.lint] select = [ - "A001", # Variable {name} is shadowing a Python builtin - "ASYNC", # flake8-async - "B002", # Python does not support the unary prefix increment - "B005", # Using .strip() with multi-character strings is misleading - "B007", # Loop control variable {name} not used within loop body - "B014", # Exception handler with duplicate exception - "B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it. - "B017", # pytest.raises(BaseException) should be considered evil - "B018", # Found useless attribute access. Either assign it to a variable or remove it. - "B023", # Function definition does not bind loop variable {name} - "B024", # `{name}` is an abstract base class, but it has no abstract methods or properties - "B026", # Star-arg unpacking after a keyword argument is strongly discouraged - "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? - "B035", # Dictionary comprehension uses static key - "B904", # Use raise from to specify exception cause - "B905", # zip() without an explicit strict= parameter - "BLE", - "C", # complexity - "COM818", # Trailing comma on bare tuple prohibited - "D", # docstrings - "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() - "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) - "E", # pycodestyle - "F", # pyflakes/autoflake - "F541", # f-string without any placeholders - "FLY", # flynt - "FURB", # refurb - "G", # flake8-logging-format - "I", # isort - "INP", # flake8-no-pep420 - "ISC", # flake8-implicit-str-concat - "ICN001", # import concentions; {name} should be imported as {asname} - "LOG", # flake8-logging - "N804", # First argument of a class method should be named cls - "N805", # First argument of a method should be named self - "N815", # Variable {name} in class scope should not be mixedCase - "PERF", # Perflint - "PGH", # pygrep-hooks - "PIE", # flake8-pie - "PL", # pylint - "PT", # flake8-pytest-style - "PTH", # flake8-pathlib - "PYI", # flake8-pyi - "RET", # flake8-return - "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 - "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 - "S306", # suspicious-mktemp-usage - "S307", # suspicious-eval-usage - "S313", # suspicious-xmlc-element-tree-usage - "S314", # suspicious-xml-element-tree-usage - "S315", # suspicious-xml-expat-reader-usage - "S316", # suspicious-xml-expat-builder-usage - "S317", # suspicious-xml-sax-usage - "S318", # suspicious-xml-mini-dom-usage - "S319", # suspicious-xml-pull-dom-usage - "S601", # paramiko-call - "S602", # subprocess-popen-with-shell-equals-true - "S604", # call-with-shell-equals-true - "S608", # hardcoded-sql-expression - "S609", # unix-command-wildcard-injection - "SIM", # flake8-simplify - "SLF", # flake8-self - "SLOT", # flake8-slots - "T100", # Trace found: {name} used - "T20", # flake8-print - "TC", # flake8-type-checking - "TID", # Tidy imports - "TRY", # tryceratops - "UP", # pyupgrade - "UP031", # Use format specifiers instead of percent format - "UP032", # Use f-string instead of `format` call - "W", # pycodestyle + "A001", # Variable {name} is shadowing a Python builtin + "ASYNC", # flake8-async + "B002", # Python does not support the unary prefix increment + "B005", # Using .strip() with multi-character strings is misleading + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it. + "B017", # pytest.raises(BaseException) should be considered evil + "B018", # Found useless attribute access. Either assign it to a variable or remove it. + "B023", # Function definition does not bind loop variable {name} + "B024", # `{name}` is an abstract base class, but it has no abstract methods or properties + "B026", # Star-arg unpacking after a keyword argument is strongly discouraged + "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? + "B035", # Dictionary comprehension uses static key + "B904", # Use raise from to specify exception cause + "B905", # zip() without an explicit strict= parameter + "BLE", + "C", # complexity + "COM818", # Trailing comma on bare tuple prohibited + "D", # docstrings + "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() + "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) + "E", # pycodestyle + "F", # pyflakes/autoflake + "F541", # f-string without any placeholders + "FLY", # flynt + "FURB", # refurb + "G", # flake8-logging-format + "I", # isort + "INP", # flake8-no-pep420 + "ISC", # flake8-implicit-str-concat + "ICN001", # import concentions; {name} should be imported as {asname} + "LOG", # flake8-logging + "N804", # First argument of a class method should be named cls + "N805", # First argument of a method should be named self + "N815", # Variable {name} in class scope should not be mixedCase + "PERF", # Perflint + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "PTH", # flake8-pathlib + "PYI", # flake8-pyi + "RET", # flake8-return + "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 + "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 + "S306", # suspicious-mktemp-usage + "S307", # suspicious-eval-usage + "S313", # suspicious-xmlc-element-tree-usage + "S314", # suspicious-xml-element-tree-usage + "S315", # suspicious-xml-expat-reader-usage + "S316", # suspicious-xml-expat-builder-usage + "S317", # suspicious-xml-sax-usage + "S318", # suspicious-xml-mini-dom-usage + "S319", # suspicious-xml-pull-dom-usage + "S601", # paramiko-call + "S602", # subprocess-popen-with-shell-equals-true + "S604", # call-with-shell-equals-true + "S608", # hardcoded-sql-expression + "S609", # unix-command-wildcard-injection + "SIM", # flake8-simplify + "SLF", # flake8-self + "SLOT", # flake8-slots + "T100", # Trace found: {name} used + "T20", # flake8-print + "TC", # flake8-type-checking + "TID", # Tidy imports + "TRY", # tryceratops + "UP", # pyupgrade + "UP031", # Use format specifiers instead of percent format + "UP032", # Use f-string instead of `format` call + "W", # pycodestyle ] ignore = [ - "ASYNC109", # Async function definition with a `timeout` parameter Use `asyncio.timeout` instead - "ASYNC110", # Use `asyncio.Event` instead of awaiting `asyncio.sleep` in a `while` loop - "D202", # No blank lines allowed after function docstring - "D203", # 1 blank line required before class docstring - "D213", # Multi-line docstring summary should start at the second line - "D406", # Section name should end with a newline - "D407", # Section name underlining - "E501", # line too long + "ASYNC109", # Async function definition with a `timeout` parameter Use `asyncio.timeout` instead + "ASYNC110", # Use `asyncio.Event` instead of awaiting `asyncio.sleep` in a `while` loop + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D406", # Section name should end with a newline + "D407", # Section name underlining + "E501", # line too long - "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives - "PLR0911", # Too many return statements ({returns} > {max_returns}) - "PLR0912", # Too many branches ({branches} > {max_branches}) - "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) - "PLR0915", # Too many statements ({statements} > {max_statements}) - "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable - "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target - "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception - "PT018", # Assertion should be broken down into multiple parts - "RUF001", # String contains ambiguous unicode character. - "RUF002", # Docstring contains ambiguous unicode character. - "RUF003", # Comment contains ambiguous unicode character. - "RUF015", # Prefer next(...) over single element slice - "SIM102", # Use a single if statement instead of nested if statements - "SIM103", # Return the condition {condition} directly - "SIM108", # Use ternary operator {contents} instead of if-else-block - "SIM115", # Use context handler for opening files + "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives + "PLR0911", # Too many return statements ({returns} > {max_returns}) + "PLR0912", # Too many branches ({branches} > {max_branches}) + "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) + "PLR0915", # Too many statements ({statements} > {max_statements}) + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target + "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception + "PT018", # Assertion should be broken down into multiple parts + "RUF001", # String contains ambiguous unicode character. + "RUF002", # Docstring contains ambiguous unicode character. + "RUF003", # Comment contains ambiguous unicode character. + "RUF015", # Prefer next(...) over single element slice + "SIM102", # Use a single if statement instead of nested if statements + "SIM103", # Return the condition {condition} directly + "SIM108", # Use ternary operator {contents} instead of if-else-block + "SIM115", # Use context handler for opening files - # Moving imports into type-checking blocks can mess with pytest.patch() - "TC001", # Move application import {} into a type-checking block - "TC002", # Move third-party import {} into a type-checking block - "TC003", # Move standard library import {} into a type-checking block - # Quotes for typing.cast generally not necessary, only for performance critical paths - "TC006", # Add quotes to type expression in typing.cast() + # Moving imports into type-checking blocks can mess with pytest.patch() + "TC001", # Move application import {} into a type-checking block + "TC002", # Move third-party import {} into a type-checking block + "TC003", # Move standard library import {} into a type-checking block + # Quotes for typing.cast generally not necessary, only for performance critical paths + "TC006", # Add quotes to type expression in typing.cast() - "TRY003", # Avoid specifying long messages outside the exception class - "TRY400", # Use `logging.exception` instead of `logging.error` - # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 - "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + "TRY003", # Avoid specifying long messages outside the exception class + "TRY400", # Use `logging.exception` instead of `logging.error` + # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 + "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` - # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules - "W191", - "E111", - "E114", - "E117", - "D206", - "D300", - "Q", - "COM812", - "COM819", + # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", + "E111", + "E114", + "E117", + "D206", + "D300", + "Q", + "COM812", + "COM819", - # Disabled because ruff does not understand type of __all__ generated by a function - "PLE0605", + # Disabled because ruff does not understand type of __all__ generated by a function + "PLE0605", ] [tool.ruff.lint.flake8-import-conventions.extend-aliases] @@ -970,9 +929,7 @@ mark-parentheses = false [tool.ruff.lint.isort] force-sort-within-sections = true -known-first-party = [ - "homeassistant", -] +known-first-party = ["homeassistant"] combine-as-imports = true split-on-trailing-comma = false diff --git a/requirements.txt b/requirements.txt index 7095fccc964..26ff191025f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,9 +3,9 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiodns==3.2.0 -aiohasupervisor==0.3.0 -aiohttp==3.11.16 +aiodns==3.3.0 +aiohasupervisor==0.3.1 +aiohttp==3.11.18 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 @@ -21,13 +21,13 @@ bcrypt==4.2.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 -fnv-hash-fast==1.4.0 +fnv-hash-fast==1.5.0 ha-ffmpeg==3.2.2 -hass-nabucasa==0.94.0 +hass-nabucasa==0.96.0 hassil==2.2.3 httpx==0.28.1 home-assistant-bluetooth==1.13.1 -home-assistant-intents==2025.3.28 +home-assistant-intents==2025.5.7 ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 @@ -35,10 +35,10 @@ mutagen==1.47.0 numpy==2.2.2 PyJWT==2.10.1 cryptography==44.0.1 -Pillow==11.1.0 -propcache==0.3.0 +Pillow==11.2.1 +propcache==0.3.1 pyOpenSSL==25.0.0 -orjson==3.10.16 +orjson==3.10.18 packaging>=23.1 psutil-home-assistant==0.0.1 pymicro-vad==1.0.1 @@ -48,16 +48,16 @@ PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 securetar==2025.2.1 -SQLAlchemy==2.0.39 +SQLAlchemy==2.0.40 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.13.0,<5.0 ulid-transform==1.4.0 urllib3>=1.26.5,<2 -uv==0.6.10 +uv==0.7.1 voluptuous==0.15.2 voluptuous-serialize==2.6.0 -voluptuous-openapi==0.0.6 -yarl==1.18.3 +voluptuous-openapi==0.0.7 +yarl==1.20.0 webrtc-models==0.3.0 -zeroconf==0.146.0 +zeroconf==0.147.0 diff --git a/requirements_all.txt b/requirements_all.txt index 9c8bf7ae9aa..da5bdddd5c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -33,7 +33,7 @@ Mastodon.py==2.0.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==11.1.0 +Pillow==11.2.1 # homeassistant.components.plex PlexAPI==4.15.16 @@ -70,7 +70,7 @@ PyMetno==0.13.0 PyMicroBot==0.0.17 # homeassistant.components.nina -PyNINA==0.3.4 +PyNINA==0.3.5 # homeassistant.components.mobile_app # homeassistant.components.owntracks @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.58.0 +PySwitchbot==0.60.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -116,7 +116,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.39 +SQLAlchemy==2.0.40 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 @@ -179,7 +179,7 @@ aioacaia==0.1.14 aioairq==0.4.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.11 +aioairzone-cloud==0.6.12 # homeassistant.components.airzone aioairzone==1.0.0 @@ -201,7 +201,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.3.2 +aioautomower==2025.5.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -210,10 +210,11 @@ aioazuredevops==2.2.1 aiobafi6==0.9.0 # homeassistant.components.aws -aiobotocore==2.13.1 +# homeassistant.components.aws_s3 +aiobotocore==2.21.1 # homeassistant.components.comelit -aiocomelit==0.11.3 +aiocomelit==0.12.0 # homeassistant.components.dhcp aiodhcpwatcher==1.1.1 @@ -222,7 +223,7 @@ aiodhcpwatcher==1.1.1 aiodiscover==2.6.1 # homeassistant.components.dnsip -aiodns==3.2.0 +aiodns==3.3.0 # homeassistant.components.duke_energy aiodukeenergy==0.3.0 @@ -243,7 +244,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.9.0 +aioesphomeapi==30.1.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -261,10 +262,10 @@ aioguardian==2022.07.0 aioharmony==0.5.2 # homeassistant.components.hassio -aiohasupervisor==0.3.0 +aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.16.3 +aiohomeconnect==0.17.0 # homeassistant.components.homekit_controller aiohomekit==3.2.14 @@ -284,6 +285,9 @@ aiokafka==0.10.0 # homeassistant.components.kef aiokef==0.2.16 +# homeassistant.components.rehlko +aiokem==0.5.10 + # homeassistant.components.lifx aiolifx-effects==0.3.2 @@ -314,12 +318,12 @@ aionanoleaf==0.2.1 # homeassistant.components.notion aionotion==2024.03.0 +# homeassistant.components.ntfy +aiontfy==0.5.1 + # homeassistant.components.nut aionut==4.3.4 -# homeassistant.components.oncue -aiooncue==0.3.9 - # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 @@ -362,7 +366,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.4.0 +aiorussound==4.5.2 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -371,7 +375,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.4.1 +aioshelly==13.6.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -413,7 +417,7 @@ aiousbwatcher==1.1.1 aiovlc==0.5.1 # homeassistant.components.vodafone_station -aiovodafone==0.6.1 +aiovodafone==0.10.0 # homeassistant.components.waqi aiowaqi==3.1.0 @@ -491,7 +495,7 @@ apprise==1.9.1 aprslib==0.7.2 # homeassistant.components.apsystems -apsystems-ez1==2.4.0 +apsystems-ez1==2.6.0 # homeassistant.components.aqualogic aqualogic==2.6 @@ -590,7 +594,7 @@ batinfo==0.4.2 # beacontools[scan]==2.1.0 # homeassistant.components.scrape -beautifulsoup4==4.12.3 +beautifulsoup4==4.13.3 # homeassistant.components.beewi_smartclim # beewi-smartclim==0.0.10 @@ -603,7 +607,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.12.0 +bleak-esphome==2.15.1 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 @@ -624,7 +628,7 @@ blockchain==1.4.4 bluecurrent-api==1.2.3 # homeassistant.components.bluemaestro -bluemaestro-ble==0.2.3 +bluemaestro-ble==0.4.1 # homeassistant.components.decora # bluepy==1.3.0 @@ -633,29 +637,29 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.5 +bluetooth-auto-recovery==1.5.1 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.26.5 +bluetooth-data-tools==1.28.1 # homeassistant.components.bond bond-async==0.2.1 # homeassistant.components.bosch_alarm -bosch-alarm-mode2==0.4.3 +bosch-alarm-mode2==0.4.6 # homeassistant.components.bosch_shc boschshcpy==0.2.91 # homeassistant.components.amazon_polly # homeassistant.components.route53 -boto3==1.34.131 +boto3==1.37.1 # homeassistant.components.aws -botocore==1.34.131 +botocore==1.37.1 # homeassistant.components.bring bring-api==1.1.0 @@ -709,7 +713,7 @@ coinbase-advanced-py==1.2.2 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==6.8.2 +colorlog==6.9.0 # homeassistant.components.color_extractor colorthief==0.2.1 @@ -758,7 +762,7 @@ debugpy==1.8.13 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==12.5.0 +deebot-client==13.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -778,7 +782,7 @@ denonavr==1.0.1 devialet==1.5.7 # homeassistant.components.devolo_home_control -devolo-home-control-api==0.18.3 +devolo-home-control-api==0.19.0 # homeassistant.components.devolo_home_network devolo-plc-api==1.5.1 @@ -802,7 +806,7 @@ dremel3dpy==2.1.1 dropmqttapi==1.0.3 # homeassistant.components.dsmr -dsmr-parser==1.4.2 +dsmr-parser==1.4.3 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 @@ -829,7 +833,7 @@ ebusdpy==0.0.17 ecoaliface==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.0.6 +eheimdigital==1.1.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 @@ -871,7 +875,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.10.1 +env-canada==0.10.2 # homeassistant.components.season ephem==4.1.6 @@ -889,7 +893,7 @@ epson-projector==0.5.1 eq3btsmart==1.4.1 # homeassistant.components.esphome -esphome-dashboard-api==1.2.3 +esphome-dashboard-api==1.3.0 # homeassistant.components.netgear_lte eternalegypt==0.0.16 @@ -948,7 +952,7 @@ flux-led==1.2.0 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.4.0 +fnv-hash-fast==1.5.0 # homeassistant.components.foobot foobot_async==1.0.0 @@ -1035,23 +1039,23 @@ google-api-python-client==2.71.0 google-cloud-pubsub==2.29.0 # homeassistant.components.google_cloud -google-cloud-speech==2.27.0 +google-cloud-speech==2.31.1 # homeassistant.components.google_cloud -google-cloud-texttospeech==2.17.2 +google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation google-genai==1.7.0 +# homeassistant.components.google_travel_time +google-maps-routing==0.6.14 + # homeassistant.components.nest google-nest-sdm==7.1.4 # homeassistant.components.google_photos google-photos-library-api==0.12.1 -# homeassistant.components.google_travel_time -googlemaps==2.5.1 - # homeassistant.components.slide # homeassistant.components.slide_local goslide-api==0.7.0 @@ -1060,7 +1064,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.43.1 +govee-ble==0.44.0 # homeassistant.components.govee_light_local govee-local-api==2.1.0 @@ -1096,7 +1100,7 @@ gstreamer-player==1.1.2 guppy3==3.1.5 # homeassistant.components.iaqualink -h2==4.1.0 +h2==4.2.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.2.2 @@ -1114,10 +1118,10 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.37.0 +habluetooth==3.48.2 # homeassistant.components.cloud -hass-nabucasa==0.94.0 +hass-nabucasa==0.96.0 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -1126,7 +1130,7 @@ hass-splunk==0.1.1 hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate[astral]==1.0.3 +hdate[astral]==1.1.0 # homeassistant.components.heatmiser heatmiserV3==2.0.3 @@ -1157,13 +1161,13 @@ hole==0.8.0 holidays==0.70 # homeassistant.components.frontend -home-assistant-frontend==20250411.0 +home-assistant-frontend==20250507.0 # homeassistant.components.conversation -home-assistant-intents==2025.3.28 +home-assistant-intents==2025.5.7 # homeassistant.components.homematicip_cloud -homematicip==1.1.7 +homematicip==2.0.1.1 # homeassistant.components.horizon horimote==0.4.1 @@ -1172,7 +1176,7 @@ horimote==0.4.1 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.10.0 +huawei-lte-api==1.11.0 # homeassistant.components.huum huum==0.7.12 @@ -1196,7 +1200,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==9.1.0 +ical==9.2.0 # homeassistant.components.caldav icalendar==6.1.0 @@ -1219,11 +1223,14 @@ igloohome-api==0.1.0 # homeassistant.components.ihc ihcsdk==2.8.5 +# homeassistant.components.imeon_inverter +imeon_inverter_api==0.3.12 + # homeassistant.components.imgw_pib imgw_pib==1.0.10 # homeassistant.components.incomfort -incomfort-client==0.6.7 +incomfort-client==0.6.8 # homeassistant.components.influxdb influxdb-client==1.24.0 @@ -1232,7 +1239,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.9.0 +inkbird-ble==0.16.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 @@ -1305,13 +1312,13 @@ lakeside==0.13 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.3 +lcn-frontend==0.2.4 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 # homeassistant.components.leaone -leaone-ble==0.1.0 +leaone-ble==0.3.0 # homeassistant.components.led_ble led-ble==1.1.7 @@ -1489,7 +1496,7 @@ nettigo-air-monitor==4.1.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.4.0 +nexia==2.7.0 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 @@ -1553,7 +1560,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==6.0.2 +odp-amsterdam==6.1.1 # homeassistant.components.oem oemthermostat==1.1.1 @@ -1571,7 +1578,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.13 +onedrive-personal-sdk==0.0.14 # homeassistant.components.onvif onvif-zeep-async==3.2.5 @@ -1583,7 +1590,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.68.2 +openai==1.76.2 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1607,7 +1614,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.11.1 +opower==0.12.0 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1675,7 +1682,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.2 +plugwise==1.7.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1719,7 +1726,7 @@ pulsectl==23.5.2 pushbullet.py==0.11.0 # homeassistant.components.pushover -pushover_complete==1.1.1 +pushover_complete==1.2.0 # homeassistant.components.pvoutput pvo==2.2.1 @@ -1752,7 +1759,7 @@ py-madvr2==1.6.32 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==2.0.5 +py-nextbusnext==2.1.2 # homeassistant.components.nightscout py-nightscout==1.2.2 @@ -1831,7 +1838,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.1.0 +pyatmo==9.0.0 # homeassistant.components.apple_tv pyatv==0.16.0 @@ -1849,7 +1856,7 @@ pybbox==0.0.5-alpha pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==2.0.0 +pyblu==2.0.1 # homeassistant.components.neato pybotvac==0.0.26 @@ -1918,7 +1925,7 @@ pydoods==1.0.2 pydrawise==2025.3.0 # homeassistant.components.android_ip_webcam -pydroid-ipcam==2.0.0 +pydroid-ipcam==3.0.0 # homeassistant.components.ebox pyebox==1.1.4 @@ -1948,13 +1955,13 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.25.5 +pyenphase==1.26.0 # homeassistant.components.envisalink pyenvisalink==4.7 # homeassistant.components.ephember -pyephember==0.3.1 +pyephember2==0.4.12 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -2047,7 +2054,7 @@ pyiskra==0.1.15 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.15 +pyisy==3.4.1 # homeassistant.components.itach pyitachip2ir==0.0.7 @@ -2077,7 +2084,7 @@ pykoplenti==1.3.0 pykrakenapi==0.1.8 # homeassistant.components.kulersky -pykulersky==0.5.2 +pykulersky==0.5.8 # homeassistant.components.kwb pykwb==0.0.8 @@ -2086,7 +2093,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==1.4.9 +pylamarzocco==2.0.0 # homeassistant.components.lastfm pylast==5.1.0 @@ -2110,7 +2117,7 @@ pylitterbot==2024.0.0 pylutron-caseta==0.24.0 # homeassistant.components.lutron -pylutron==0.2.16 +pylutron==0.2.18 # homeassistant.components.mailgun pymailgunner==1.4 @@ -2121,15 +2128,15 @@ pymata-express==1.19 # homeassistant.components.mediaroom pymediaroom==0.6.5.4 -# homeassistant.components.melcloud -pymelcloud==2.5.9 - # homeassistant.components.meteoclimatic pymeteoclimatic==0.1.0 # homeassistant.components.assist_pipeline pymicro-vad==1.0.1 +# homeassistant.components.miele +pymiele==0.4.3 + # homeassistant.components.xiaomi_tv pymitv==1.4.3 @@ -2208,7 +2215,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.5 +pyoverkiz==1.17.1 # homeassistant.components.onewire pyownet==0.10.0.post1 @@ -2223,7 +2230,7 @@ pypca==0.0.7 pypck==0.8.5 # homeassistant.components.pglab -pypglab==0.0.3 +pypglab==0.0.5 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -2283,13 +2290,13 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2024.11.0 +pyschlage==2025.4.0 # homeassistant.components.sensibo pysensibo==1.1.0 # homeassistant.components.serial -pyserial-asyncio-fast==0.14 +pyserial-asyncio-fast==0.16 # homeassistant.components.acer_projector # homeassistant.components.crownstone @@ -2319,7 +2326,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==3.0.5 +pysmartthings==3.2.1 # homeassistant.components.smarty pysmarty2==0.10.2 @@ -2352,7 +2359,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.12.0 # homeassistant.components.stiebel_eltron -pystiebeleltron==0.0.1.dev2 +pystiebeleltron==0.1.0 # homeassistant.components.suez_water pysuezV2==2.0.4 @@ -2430,7 +2437,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.2 +python-linkplay==0.2.4 # homeassistant.components.lirc # python-lirc==1.2.3 @@ -2438,6 +2445,9 @@ python-linkplay==0.2.2 # homeassistant.components.matter python-matter-server==7.0.0 +# homeassistant.components.melcloud +python-melcloud==0.1.0 + # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -2470,7 +2480,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.16.1 +python-roborock==2.18.2 # homeassistant.components.smarttub python-smarttub==0.0.39 @@ -2482,7 +2492,7 @@ python-snoo==0.6.5 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.11 +python-tado==0.18.14 # homeassistant.components.technove python-technove==2.0.0 @@ -2564,10 +2574,10 @@ pywemo==1.4.0 pywilight==0.0.74 # homeassistant.components.wiz -pywizlight==0.5.14 +pywizlight==0.6.2 # homeassistant.components.wmspro -pywmspro==0.2.1 +pywmspro==0.2.2 # homeassistant.components.ws66i pyws66i==1.1 @@ -2585,7 +2595,7 @@ pyzbar==0.1.7 pyzerproc==0.4.8 # homeassistant.components.qbittorrent -qbittorrent-api==2024.2.59 +qbittorrent-api==2024.9.67 # homeassistant.components.qbus qbusmqttapi==1.3.0 @@ -2621,7 +2631,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.9 +renault-api==0.3.1 # homeassistant.components.renson renson-endura-delta==1.7.2 @@ -2709,13 +2719,13 @@ sense-energy==0.13.7 sensirion-ble==0.1.1 # homeassistant.components.sensorpro -sensorpro-ble==0.5.3 +sensorpro-ble==0.7.0 # homeassistant.components.sensorpush_cloud -sensorpush-api==2.1.1 +sensorpush-api==2.1.2 # homeassistant.components.sensorpush -sensorpush-ble==1.7.1 +sensorpush-ble==1.9.0 # homeassistant.components.sensorpush_cloud sensorpush-ha==1.3.2 @@ -2849,7 +2859,7 @@ systembridgeconnector==4.1.5 systembridgemodels==4.2.4 # homeassistant.components.tailscale -tailscale==0.6.1 +tailscale==0.6.2 # homeassistant.components.tank_utility tank-utility==1.5.0 @@ -2887,7 +2897,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.12 +teslemetry-stream==0.7.7 # homeassistant.components.tessie tessie-api==0.1.1 @@ -2896,10 +2906,10 @@ tessie-api==0.1.1 # tf-models-official==2.5.0 # homeassistant.components.thermobeacon -thermobeacon-ble==0.8.1 +thermobeacon-ble==0.10.0 # homeassistant.components.thermopro -thermopro-ble==0.11.0 +thermopro-ble==0.13.0 # homeassistant.components.thingspeak thingspeak==1.0.0 @@ -2965,7 +2975,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.5.1 +uiprotect==7.6.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2991,13 +3001,13 @@ upcloud-api==2.6.0 # homeassistant.components.huawei_lte # homeassistant.components.syncthru # homeassistant.components.zwave_me -url-normalize==1.4.3 +url-normalize==2.2.1 # homeassistant.components.uvc uvcclient==0.12.1 # homeassistant.components.roborock -vacuum-map-parser-roborock==0.1.2 +vacuum-map-parser-roborock==0.1.4 # homeassistant.components.vallox vallox-websocket-api==5.3.0 @@ -3006,7 +3016,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.3.1 +velbus-aio==2025.4.2 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -3015,7 +3025,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.1 +voip-utils==0.3.2 # homeassistant.components.volkszaehler volkszaehler==0.4.0 @@ -3040,7 +3050,7 @@ vultr==0.1.2 wakeonlan==2.1.0 # homeassistant.components.wallbox -wallbox==0.8.0 +wallbox==0.9.0 # homeassistant.components.folder_watcher watchdog==6.0.0 @@ -3064,10 +3074,10 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.2.26 +weheat==2025.4.29 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.19.1 +whirlpool-sixth-sense==0.20.0 # homeassistant.components.whois whois==0.9.27 @@ -3091,7 +3101,7 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.33.0 +xiaomi-ble==0.38.0 # homeassistant.components.knx xknx==3.6.0 @@ -3115,7 +3125,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.7 +yalexs-ble==2.6.0 # homeassistant.components.august # homeassistant.components.yale @@ -3128,7 +3138,7 @@ yeelight==0.7.16 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.4.9 +yolink-api==0.5.2 # homeassistant.components.youless youless-api==2.2.0 @@ -3137,7 +3147,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.03.26 +yt-dlp[default]==2025.03.31 # homeassistant.components.zabbix zabbix-utils==2.0.2 @@ -3146,13 +3156,13 @@ zabbix-utils==2.0.2 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.146.0 +zeroconf==0.147.0 # homeassistant.components.zeversolar zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.56 +zha==0.0.57 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 @@ -3164,7 +3174,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.62.0 +zwave-js-server-python==0.63.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index c7bb9b11b87..80be991cfcd 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -10,11 +10,12 @@ astroid==3.3.9 coverage==7.6.12 freezegun==1.5.1 +go2rtc-client==0.1.2 license-expression==30.4.1 mock-open==1.4.0 -mypy-dev==1.16.0a7 +mypy-dev==1.16.0a8 pre-commit==4.0.0 -pydantic==2.10.6 +pydantic==2.11.3 pylint==3.3.6 pylint-per-file-ignores==1.4.0 pipdeptree==2.25.1 @@ -34,21 +35,19 @@ requests-mock==1.12.1 respx==0.22.0 syrupy==4.8.1 tqdm==4.67.1 -types-aiofiles==24.1.0.20241221 +types-aiofiles==24.1.0.20250326 types-atomicwrites==1.4.5.1 -types-croniter==5.0.1.20241205 -types-beautifulsoup4==4.12.0.20250204 +types-croniter==6.0.0.20250411 types-caldav==1.3.0.20241107 types-chardet==0.1.5 -types-decorator==5.1.8.20250121 +types-decorator==5.2.0.20250324 types-pexpect==4.9.0.20241208 -types-pillow==10.2.0.20240822 -types-protobuf==5.29.1.20241207 -types-psutil==6.1.0.20241221 -types-pyserial==3.5.0.20250130 +types-protobuf==5.29.1.20250403 +types-psutil==7.0.0.20250401 +types-pyserial==3.5.0.20250326 types-python-dateutil==2.9.0.20241206 types-python-slugify==8.0.2.20240310 -types-pytz==2025.1.0.20250204 -types-PyYAML==6.0.12.20241230 +types-pytz==2025.2.0.20250326 +types-PyYAML==6.0.12.20250402 types-requests==2.31.0.3 types-xmltodict==0.13.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2abcb85e499..c7f6f484a70 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -33,7 +33,7 @@ Mastodon.py==2.0.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==11.1.0 +Pillow==11.2.1 # homeassistant.components.plex PlexAPI==4.15.16 @@ -67,7 +67,7 @@ PyMetno==0.13.0 PyMicroBot==0.0.17 # homeassistant.components.nina -PyNINA==0.3.4 +PyNINA==0.3.5 # homeassistant.components.mobile_app # homeassistant.components.owntracks @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.58.0 +PySwitchbot==0.60.1 # homeassistant.components.syncthru PySyncThru==0.8.0 @@ -110,7 +110,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.39 +SQLAlchemy==2.0.40 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 @@ -167,7 +167,7 @@ aioacaia==0.1.14 aioairq==0.4.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.11 +aioairzone-cloud==0.6.12 # homeassistant.components.airzone aioairzone==1.0.0 @@ -189,7 +189,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.3.2 +aioautomower==2025.5.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -198,10 +198,11 @@ aioazuredevops==2.2.1 aiobafi6==0.9.0 # homeassistant.components.aws -aiobotocore==2.13.1 +# homeassistant.components.aws_s3 +aiobotocore==2.21.1 # homeassistant.components.comelit -aiocomelit==0.11.3 +aiocomelit==0.12.0 # homeassistant.components.dhcp aiodhcpwatcher==1.1.1 @@ -210,7 +211,7 @@ aiodhcpwatcher==1.1.1 aiodiscover==2.6.1 # homeassistant.components.dnsip -aiodns==3.2.0 +aiodns==3.3.0 # homeassistant.components.duke_energy aiodukeenergy==0.3.0 @@ -231,7 +232,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.9.0 +aioesphomeapi==30.1.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -246,10 +247,10 @@ aioguardian==2022.07.0 aioharmony==0.5.2 # homeassistant.components.hassio -aiohasupervisor==0.3.0 +aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.16.3 +aiohomeconnect==0.17.0 # homeassistant.components.homekit_controller aiohomekit==3.2.14 @@ -266,6 +267,9 @@ aioimaplib==2.0.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 +# homeassistant.components.rehlko +aiokem==0.5.10 + # homeassistant.components.lifx aiolifx-effects==0.3.2 @@ -296,12 +300,12 @@ aionanoleaf==0.2.1 # homeassistant.components.notion aionotion==2024.03.0 +# homeassistant.components.ntfy +aiontfy==0.5.1 + # homeassistant.components.nut aionut==4.3.4 -# homeassistant.components.oncue -aiooncue==0.3.9 - # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 @@ -344,7 +348,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.4.0 +aiorussound==4.5.2 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -353,7 +357,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.4.1 +aioshelly==13.6.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -395,7 +399,7 @@ aiousbwatcher==1.1.1 aiovlc==0.5.1 # homeassistant.components.vodafone_station -aiovodafone==0.6.1 +aiovodafone==0.10.0 # homeassistant.components.waqi aiowaqi==3.1.0 @@ -464,7 +468,7 @@ apprise==1.9.1 aprslib==0.7.2 # homeassistant.components.apsystems -apsystems-ez1==2.4.0 +apsystems-ez1==2.6.0 # homeassistant.components.aranet aranet4==2.5.1 @@ -527,14 +531,14 @@ babel==2.15.0 base36==0.1.1 # homeassistant.components.scrape -beautifulsoup4==4.12.3 +beautifulsoup4==4.13.3 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.12.0 +bleak-esphome==2.15.1 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 @@ -552,31 +556,31 @@ blinkpy==0.23.0 bluecurrent-api==1.2.3 # homeassistant.components.bluemaestro -bluemaestro-ble==0.2.3 +bluemaestro-ble==0.4.1 # homeassistant.components.bluetooth bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.5 +bluetooth-auto-recovery==1.5.1 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.26.5 +bluetooth-data-tools==1.28.1 # homeassistant.components.bond bond-async==0.2.1 # homeassistant.components.bosch_alarm -bosch-alarm-mode2==0.4.3 +bosch-alarm-mode2==0.4.6 # homeassistant.components.bosch_shc boschshcpy==0.2.91 # homeassistant.components.aws -botocore==1.34.131 +botocore==1.37.1 # homeassistant.components.bring bring-api==1.1.0 @@ -612,7 +616,7 @@ coinbase-advanced-py==1.2.2 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==6.8.2 +colorlog==6.9.0 # homeassistant.components.color_extractor colorthief==0.2.1 @@ -649,7 +653,7 @@ dbus-fast==2.43.0 debugpy==1.8.13 # homeassistant.components.ecovacs -deebot-client==12.5.0 +deebot-client==13.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -669,7 +673,7 @@ denonavr==1.0.1 devialet==1.5.7 # homeassistant.components.devolo_home_control -devolo-home-control-api==0.18.3 +devolo-home-control-api==0.19.0 # homeassistant.components.devolo_home_network devolo-plc-api==1.5.1 @@ -690,7 +694,7 @@ dremel3dpy==2.1.1 dropmqttapi==1.0.3 # homeassistant.components.dsmr -dsmr-parser==1.4.2 +dsmr-parser==1.4.3 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 @@ -708,7 +712,7 @@ eagle100==0.1.1 easyenergy==2.1.2 # homeassistant.components.eheimdigital -eheimdigital==1.0.6 +eheimdigital==1.1.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 @@ -741,7 +745,7 @@ energyzero==2.1.1 enocean==0.50 # homeassistant.components.environment_canada -env-canada==0.10.1 +env-canada==0.10.2 # homeassistant.components.season ephem==4.1.6 @@ -759,7 +763,7 @@ epson-projector==0.5.1 eq3btsmart==1.4.1 # homeassistant.components.esphome -esphome-dashboard-api==1.2.3 +esphome-dashboard-api==1.3.0 # homeassistant.components.netgear_lte eternalegypt==0.0.16 @@ -808,7 +812,7 @@ flux-led==1.2.0 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.4.0 +fnv-hash-fast==1.5.0 # homeassistant.components.foobot foobot_async==1.0.0 @@ -886,23 +890,23 @@ google-api-python-client==2.71.0 google-cloud-pubsub==2.29.0 # homeassistant.components.google_cloud -google-cloud-speech==2.27.0 +google-cloud-speech==2.31.1 # homeassistant.components.google_cloud -google-cloud-texttospeech==2.17.2 +google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation google-genai==1.7.0 +# homeassistant.components.google_travel_time +google-maps-routing==0.6.14 + # homeassistant.components.nest google-nest-sdm==7.1.4 # homeassistant.components.google_photos google-photos-library-api==0.12.1 -# homeassistant.components.google_travel_time -googlemaps==2.5.1 - # homeassistant.components.slide # homeassistant.components.slide_local goslide-api==0.7.0 @@ -911,7 +915,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.43.1 +govee-ble==0.44.0 # homeassistant.components.govee_light_local govee-local-api==2.1.0 @@ -938,7 +942,7 @@ gspread==5.5.0 guppy3==3.1.5 # homeassistant.components.iaqualink -h2==4.1.0 +h2==4.2.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.2.2 @@ -949,20 +953,23 @@ ha-iotawattpy==0.1.2 # homeassistant.components.philips_js ha-philipsjs==3.2.2 +# homeassistant.components.homeassistant_hardware +ha-silabs-firmware-client==0.2.0 + # homeassistant.components.habitica habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.37.0 +habluetooth==3.48.2 # homeassistant.components.cloud -hass-nabucasa==0.94.0 +hass-nabucasa==0.96.0 # homeassistant.components.conversation hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate[astral]==1.0.3 +hdate[astral]==1.1.0 # homeassistant.components.here_travel_time here-routing==1.0.1 @@ -984,19 +991,19 @@ hole==0.8.0 holidays==0.70 # homeassistant.components.frontend -home-assistant-frontend==20250411.0 +home-assistant-frontend==20250507.0 # homeassistant.components.conversation -home-assistant-intents==2025.3.28 +home-assistant-intents==2025.5.7 # homeassistant.components.homematicip_cloud -homematicip==1.1.7 +homematicip==2.0.1.1 # homeassistant.components.remember_the_milk httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.10.0 +huawei-lte-api==1.11.0 # homeassistant.components.huum huum==0.7.12 @@ -1014,7 +1021,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==9.1.0 +ical==9.2.0 # homeassistant.components.caldav icalendar==6.1.0 @@ -1031,11 +1038,14 @@ ifaddr==0.2.0 # homeassistant.components.igloohome igloohome-api==0.1.0 +# homeassistant.components.imeon_inverter +imeon_inverter_api==0.3.12 + # homeassistant.components.imgw_pib imgw_pib==1.0.10 # homeassistant.components.incomfort -incomfort-client==0.6.7 +incomfort-client==0.6.8 # homeassistant.components.influxdb influxdb-client==1.24.0 @@ -1044,7 +1054,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.9.0 +inkbird-ble==0.16.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 @@ -1102,13 +1112,13 @@ lacrosse-view==1.1.1 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.3 +lcn-frontend==0.2.4 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 # homeassistant.components.leaone -leaone-ble==0.1.0 +leaone-ble==0.3.0 # homeassistant.components.led_ble led-ble==1.1.7 @@ -1250,7 +1260,7 @@ netmap==0.7.0.2 nettigo-air-monitor==4.1.0 # homeassistant.components.nexia -nexia==2.4.0 +nexia==2.7.0 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 @@ -1302,7 +1312,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==6.0.2 +odp-amsterdam==6.1.1 # homeassistant.components.ohme ohme==1.5.1 @@ -1317,7 +1327,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.13 +onedrive-personal-sdk==0.0.14 # homeassistant.components.onvif onvif-zeep-async==3.2.5 @@ -1329,7 +1339,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.68.2 +openai==1.76.2 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1341,7 +1351,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.11.1 +opower==0.12.0 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1386,7 +1396,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.2 +plugwise==1.7.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1418,7 +1428,7 @@ psutil==7.0.0 pushbullet.py==0.11.0 # homeassistant.components.pushover -pushover_complete==1.1.1 +pushover_complete==1.2.0 # homeassistant.components.pvoutput pvo==2.2.1 @@ -1451,7 +1461,7 @@ py-madvr2==1.6.32 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==2.0.5 +py-nextbusnext==2.1.2 # homeassistant.components.nightscout py-nightscout==1.2.2 @@ -1509,7 +1519,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.1.0 +pyatmo==9.0.0 # homeassistant.components.apple_tv pyatv==0.16.0 @@ -1524,7 +1534,7 @@ pybalboa==1.1.3 pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==2.0.0 +pyblu==2.0.1 # homeassistant.components.neato pybotvac==0.0.26 @@ -1566,7 +1576,7 @@ pydiscovergy==3.0.2 pydrawise==2025.3.0 # homeassistant.components.android_ip_webcam -pydroid-ipcam==2.0.0 +pydroid-ipcam==3.0.0 # homeassistant.components.ecoforest pyecoforest==0.4.0 @@ -1590,7 +1600,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.25.5 +pyenphase==1.26.0 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1668,7 +1678,7 @@ pyiskra==0.1.15 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.15 +pyisy==3.4.1 # homeassistant.components.ituran pyituran==0.1.4 @@ -1695,10 +1705,10 @@ pykoplenti==1.3.0 pykrakenapi==0.1.8 # homeassistant.components.kulersky -pykulersky==0.5.2 +pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==1.4.9 +pylamarzocco==2.0.0 # homeassistant.components.lastfm pylast==5.1.0 @@ -1722,7 +1732,7 @@ pylitterbot==2024.0.0 pylutron-caseta==0.24.0 # homeassistant.components.lutron -pylutron==0.2.16 +pylutron==0.2.18 # homeassistant.components.mailgun pymailgunner==1.4 @@ -1730,15 +1740,15 @@ pymailgunner==1.4 # homeassistant.components.firmata pymata-express==1.19 -# homeassistant.components.melcloud -pymelcloud==2.5.9 - # homeassistant.components.meteoclimatic pymeteoclimatic==0.1.0 # homeassistant.components.assist_pipeline pymicro-vad==1.0.1 +# homeassistant.components.miele +pymiele==0.4.3 + # homeassistant.components.mochad pymochad==0.2.0 @@ -1802,7 +1812,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.5 +pyoverkiz==1.17.1 # homeassistant.components.onewire pyownet==0.10.0.post1 @@ -1814,7 +1824,7 @@ pypalazzetti==0.1.19 pypck==0.8.5 # homeassistant.components.pglab -pypglab==0.0.3 +pypglab==0.0.5 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -1862,7 +1872,7 @@ pyrympro==0.0.9 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2024.11.0 +pyschlage==2025.4.0 # homeassistant.components.sensibo pysensibo==1.1.0 @@ -1889,7 +1899,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==3.0.5 +pysmartthings==3.2.1 # homeassistant.components.smarty pysmarty2==0.10.2 @@ -1921,6 +1931,9 @@ pyspeex-noise==1.0.2 # homeassistant.components.squeezebox pysqueezebox==0.12.0 +# homeassistant.components.stiebel_eltron +pystiebeleltron==0.1.0 + # homeassistant.components.suez_water pysuezV2==2.0.4 @@ -1967,11 +1980,14 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.2 +python-linkplay==0.2.4 # homeassistant.components.matter python-matter-server==7.0.0 +# homeassistant.components.melcloud +python-melcloud==0.1.0 + # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -2001,7 +2017,7 @@ python-picnic-api2==1.2.4 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.16.1 +python-roborock==2.18.2 # homeassistant.components.smarttub python-smarttub==0.0.39 @@ -2013,7 +2029,7 @@ python-snoo==0.6.5 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.11 +python-tado==0.18.14 # homeassistant.components.technove python-technove==2.0.0 @@ -2080,10 +2096,10 @@ pywemo==1.4.0 pywilight==0.0.74 # homeassistant.components.wiz -pywizlight==0.5.14 +pywizlight==0.6.2 # homeassistant.components.wmspro -pywmspro==0.2.1 +pywmspro==0.2.2 # homeassistant.components.ws66i pyws66i==1.1 @@ -2095,7 +2111,7 @@ pyyardian==1.1.1 pyzerproc==0.4.8 # homeassistant.components.qbittorrent -qbittorrent-api==2024.2.59 +qbittorrent-api==2024.9.67 # homeassistant.components.qbus qbusmqttapi==1.3.0 @@ -2122,7 +2138,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.9 +renault-api==0.3.1 # homeassistant.components.renson renson-endura-delta==1.7.2 @@ -2186,13 +2202,13 @@ sense-energy==0.13.7 sensirion-ble==0.1.1 # homeassistant.components.sensorpro -sensorpro-ble==0.5.3 +sensorpro-ble==0.7.0 # homeassistant.components.sensorpush_cloud -sensorpush-api==2.1.1 +sensorpush-api==2.1.2 # homeassistant.components.sensorpush -sensorpush-ble==1.7.1 +sensorpush-ble==1.9.0 # homeassistant.components.sensorpush_cloud sensorpush-ha==1.3.2 @@ -2302,7 +2318,7 @@ systembridgeconnector==4.1.5 systembridgemodels==4.2.4 # homeassistant.components.tailscale -tailscale==0.6.1 +tailscale==0.6.2 # homeassistant.components.tellduslive tellduslive==0.10.12 @@ -2325,16 +2341,16 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.12 +teslemetry-stream==0.7.7 # homeassistant.components.tessie tessie-api==0.1.1 # homeassistant.components.thermobeacon -thermobeacon-ble==0.8.1 +thermobeacon-ble==0.10.0 # homeassistant.components.thermopro -thermopro-ble==0.11.0 +thermopro-ble==0.13.0 # homeassistant.components.lg_thinq thinqconnect==1.0.5 @@ -2388,7 +2404,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.5.1 +uiprotect==7.6.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2396,6 +2412,9 @@ ultraheat-api==0.5.7 # homeassistant.components.unifiprotect unifi-discovery==1.2.0 +# homeassistant.components.homeassistant_hardware +universal-silabs-flasher==0.0.30 + # homeassistant.components.upb upb-lib==0.6.1 @@ -2405,13 +2424,13 @@ upcloud-api==2.6.0 # homeassistant.components.huawei_lte # homeassistant.components.syncthru # homeassistant.components.zwave_me -url-normalize==1.4.3 +url-normalize==2.2.1 # homeassistant.components.uvc uvcclient==0.12.1 # homeassistant.components.roborock -vacuum-map-parser-roborock==0.1.2 +vacuum-map-parser-roborock==0.1.4 # homeassistant.components.vallox vallox-websocket-api==5.3.0 @@ -2420,7 +2439,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.3.1 +velbus-aio==2025.4.2 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2429,7 +2448,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.1 +voip-utils==0.3.2 # homeassistant.components.volvooncall volvooncall==0.10.3 @@ -2448,7 +2467,7 @@ vultr==0.1.2 wakeonlan==2.1.0 # homeassistant.components.wallbox -wallbox==0.8.0 +wallbox==0.9.0 # homeassistant.components.folder_watcher watchdog==6.0.0 @@ -2466,10 +2485,10 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.2.26 +weheat==2025.4.29 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.19.1 +whirlpool-sixth-sense==0.20.0 # homeassistant.components.whois whois==0.9.27 @@ -2490,7 +2509,7 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.33.0 +xiaomi-ble==0.38.0 # homeassistant.components.knx xknx==3.6.0 @@ -2511,7 +2530,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.7 +yalexs-ble==2.6.0 # homeassistant.components.august # homeassistant.components.yale @@ -2521,7 +2540,7 @@ yalexs==8.10.0 yeelight==0.7.16 # homeassistant.components.yolink -yolink-api==0.4.9 +yolink-api==0.5.2 # homeassistant.components.youless youless-api==2.2.0 @@ -2530,22 +2549,22 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.03.26 +yt-dlp[default]==2025.03.31 # homeassistant.components.zamg zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.146.0 +zeroconf==0.147.0 # homeassistant.components.zeversolar zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.56 +zha==0.0.57 # homeassistant.components.zwave_js -zwave-js-server-python==0.62.0 +zwave-js-server-python==0.63.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 4f8db92b1ba..b4e18ea5962 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -117,9 +117,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.67.1 -grpcio-status==1.67.1 -grpcio-reflection==1.67.1 +grpcio==1.71.0 +grpcio-status==1.71.0 +grpcio-reflection==1.71.0 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 @@ -159,7 +159,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.10.6 +pydantic==2.11.3 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 @@ -271,7 +271,8 @@ def has_tests(module: str) -> bool: Test if exists: tests/components/hue/__init__.py """ path = ( - Path(module.replace(".", "/").replace("homeassistant", "tests")) / "__init__.py" + Path(module.replace(".", "/").replace("homeassistant", "tests", 1)) + / "__init__.py" ) return path.exists() diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 52ea79d32fe..ee932280201 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -84,37 +84,6 @@ class ImportCollector(ast.NodeVisitor): if name_node.name.startswith("homeassistant.components."): self._add_reference(name_node.name.split(".")[2]) - def visit_Attribute(self, node: ast.Attribute) -> None: - """Visit Attribute node.""" - # hass.components.hue.async_create() - # Name(id=hass) - # .Attribute(attr=hue) - # .Attribute(attr=async_create) - - # self.hass.components.hue.async_create() - # Name(id=self) - # .Attribute(attr=hass) or .Attribute(attr=_hass) - # .Attribute(attr=hue) - # .Attribute(attr=async_create) - if ( - isinstance(node.value, ast.Attribute) - and node.value.attr == "components" - and ( - ( - isinstance(node.value.value, ast.Name) - and node.value.value.id == "hass" - ) - or ( - isinstance(node.value.value, ast.Attribute) - and node.value.value.attr in ("hass", "_hass") - ) - ) - ): - self._add_reference(node.attr) - else: - # Have it visit other kids - self.generic_visit(node) - ALLOWED_USED_COMPONENTS = { *{platform.value for platform in Platform}, @@ -173,11 +142,6 @@ IGNORE_VIOLATIONS = { "logbook", # Temporary needed for migration until 2024.10 ("conversation", "assist_pipeline"), - # The onboarding integration provides limited backup and cloud APIs for use - # during onboarding. The onboarding integration waits for the backup manager - # and cloud to be ready before calling any backup or cloud functionality. - ("onboarding", "backup"), - ("onboarding", "cloud"), } diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index bfdb61096b6..306b5901370 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.6.10,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.7.1,source=/uv,target=/bin/uv \ # Uv creates a lock file in /tmp --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.10,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.25.1 tqdm==4.67.1 ruff==0.11.0 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.3.28 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.5.7 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index ea6e657ec50..5df24a1dc0d 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -256,7 +256,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "coinbase", "color_extractor", "comed_hourly_pricing", - "comelit", "comfoconnect", "command_line", "compensation", @@ -361,7 +360,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "epson", "eq3btsmart", "escea", - "esphome", "etherscan", "eufy", "eufylife_ble", @@ -440,7 +438,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "goalzero", "gogogate2", "goodwe", - "google", "google_assistant", "google_assistant_sdk", "google_cloud", @@ -513,7 +510,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "iglo", "ign_sismologia", "ihc", - "imgw_pib", "improv_ble", "influxdb", "inkbird", @@ -704,7 +700,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "nibe_heatpump", "nice_go", "nightscout", - "niko_home_control", "nilu", "nina", "nissan_leaf", @@ -896,7 +891,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "sfr_box", "sharkiq", "shell_command", - "shelly", "shodan", "shopping_list", "sia", @@ -923,7 +917,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "smarttub", "smarty", "smhi", - "smlight", "sms", "smtp", "snapcast", @@ -972,10 +965,8 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "switch_as_x", "switchbee", "switchbot_cloud", - "switcher_kis", "switchmate", "syncthing", - "syncthru", "synology_chat", "synology_dsm", "synology_srm", @@ -1065,7 +1056,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "upcloud", "upnp", "uptime", - "uptimerobot", "usb", "usgs_earthquakes_feed", "utility_meter", @@ -1107,7 +1097,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "weatherkit", "webmin", "wemo", - "whirlpool", "whois", "wiffi", "wilight", @@ -1309,7 +1298,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "coinbase", "color_extractor", "comed_hourly_pricing", - "comelit", "comfoconnect", "command_line", "compensation", @@ -1406,7 +1394,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "energy", "energyzero", "enigma2", - "enphase_envoy", "enocean", "entur_public_transport", "environment_canada", @@ -1417,7 +1404,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "epson", "eq3btsmart", "escea", - "esphome", "etherscan", "eufy", "eufylife_ble", @@ -1574,7 +1560,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "ign_sismologia", "ihc", "imap", - "imgw_pib", "improv_ble", "influxdb", "inkbird", @@ -1997,7 +1982,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "smarttub", "smarty", "smhi", - "smlight", "sms", "smtp", "snapcast", @@ -2144,7 +2128,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "upcloud", "upnp", "uptime", - "uptimerobot", "usb", "usgs_earthquakes_feed", "utility_meter", @@ -2189,7 +2172,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "webmin", "weheat", "wemo", - "whirlpool", "whois", "wiffi", "wilight", diff --git a/script/licenses.py b/script/licenses.py index aed3bec9998..f801603738a 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -208,7 +208,6 @@ EXCEPTIONS = { # https://github.com/jaraco/skeleton/pull/170 # https://github.com/jaraco/skeleton/pull/171 "jaraco.itertools", # MIT - https://github.com/jaraco/jaraco.itertools/issues/21 - "setuptools", # MIT } TODO = { diff --git a/script/quality_scale_summary.py b/script/quality_scale_summary.py new file mode 100644 index 00000000000..b93eab81451 --- /dev/null +++ b/script/quality_scale_summary.py @@ -0,0 +1,89 @@ +"""Generate a summary of integration quality scales. + +Run with python3 -m script.quality_scale_summary +Data collected at https://docs.google.com/spreadsheets/d/1xEiwovRJyPohAv8S4ad2LAB-0A38s1HWmzHng8v-4NI +""" + +import csv +from pathlib import Path +import sys + +from homeassistant.const import __version__ as current_version +from homeassistant.util.json import load_json + +COMPONENTS_DIR = Path("homeassistant/components") + + +def generate_quality_scale_summary() -> list[str, int]: + """Generate a summary of integration quality scales.""" + quality_scales = { + "virtual": 0, + "unknown": 0, + "legacy": 0, + "internal": 0, + "bronze": 0, + "silver": 0, + "gold": 0, + "platinum": 0, + } + + for manifest_path in COMPONENTS_DIR.glob("*/manifest.json"): + manifest = load_json(manifest_path) + + if manifest.get("integration_type") == "virtual": + quality_scales["virtual"] += 1 + elif quality_scale := manifest.get("quality_scale"): + quality_scales[quality_scale] += 1 + else: + quality_scales["unknown"] += 1 + + return quality_scales + + +def output_csv(quality_scales: dict[str, int], print_header: bool) -> None: + """Output the quality scale summary as CSV.""" + writer = csv.writer(sys.stdout) + if print_header: + writer.writerow( + [ + "Version", + "Total", + "Virtual", + "Unknown", + "Legacy", + "Internal", + "Bronze", + "Silver", + "Gold", + "Platinum", + ] + ) + + # Calculate total + total = sum(quality_scales.values()) + + # Write the summary + writer.writerow( + [ + current_version, + total, + quality_scales["virtual"], + quality_scales["unknown"], + quality_scales["legacy"], + quality_scales["internal"], + quality_scales["bronze"], + quality_scales["silver"], + quality_scales["gold"], + quality_scales["platinum"], + ] + ) + + +def main() -> None: + """Run the script.""" + quality_scales = generate_quality_scale_summary() + output_csv(quality_scales, "--header" in sys.argv) + + +if __name__ == "__main__": + main() diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index 7f5355b3cc0..cb96c9396c2 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -10,9 +10,8 @@ from homeassistant.auth.permissions.entities import ( from homeassistant.auth.permissions.models import PermissionLookup from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.entity_registry import RegistryEntry -from tests.common import mock_device_registry, mock_registry +from tests.common import RegistryEntryWithDefaults, mock_device_registry, mock_registry def test_entities_none() -> None: @@ -156,13 +155,13 @@ def test_entities_device_id_boolean(hass: HomeAssistant) -> None: entity_registry = mock_registry( hass, { - "test_domain.allowed": RegistryEntry( + "test_domain.allowed": RegistryEntryWithDefaults( entity_id="test_domain.allowed", unique_id="1234", platform="test_platform", device_id="mock-allowed-dev-id", ), - "test_domain.not_allowed": RegistryEntry( + "test_domain.not_allowed": RegistryEntryWithDefaults( entity_id="test_domain.not_allowed", unique_id="5678", platform="test_platform", @@ -196,7 +195,7 @@ def test_entities_areas_area_true(hass: HomeAssistant) -> None: entity_registry = mock_registry( hass, { - "light.kitchen": RegistryEntry( + "light.kitchen": RegistryEntryWithDefaults( entity_id="light.kitchen", unique_id="1234", platform="test_platform", diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py index dd2ce65b480..42a5ba80643 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -19,18 +19,18 @@ from homeassistant.setup import async_setup_component @pytest.fixture -def data(hass: HomeAssistant) -> hass_auth.Data: +async def data(hass: HomeAssistant) -> hass_auth.Data: """Create a loaded data class.""" data = hass_auth.Data(hass) - hass.loop.run_until_complete(data.async_load()) + await data.async_load() return data @pytest.fixture -def legacy_data(hass: HomeAssistant) -> hass_auth.Data: +async def legacy_data(hass: HomeAssistant) -> hass_auth.Data: """Create a loaded legacy data class.""" data = hass_auth.Data(hass) - hass.loop.run_until_complete(data.async_load()) + await data.async_load() data.is_legacy = True return data diff --git a/tests/common.py b/tests/common.py index f426d2aebd2..d439021a9df 100644 --- a/tests/common.py +++ b/tests/common.py @@ -30,6 +30,7 @@ from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 from annotatedyaml import load_yaml_dict, loader as yaml_loader +import attr import pytest from syrupy import SnapshotAssertion import voluptuous as vol @@ -46,6 +47,11 @@ from homeassistant.components import device_automation, persistent_notification from homeassistant.components.device_automation import ( # noqa: F401 _async_get_device_automation_capabilities as async_get_device_automation_capabilities, ) +from homeassistant.components.logger import ( + DOMAIN as LOGGER_DOMAIN, + SERVICE_SET_LEVEL, + _clear_logger_overwrites, +) from homeassistant.config import IntegrationConfigInfo, async_process_component_config from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import ( @@ -93,7 +99,7 @@ from homeassistant.helpers.entity_platform import ( ) from homeassistant.helpers.json import JSONEncoder, _orjson_default_encoder, json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt as dt_util, ulid as ulid_util +from homeassistant.util import dt as dt_util, ulid as ulid_util, uuid as uuid_util from homeassistant.util.async_ import ( _SHUTDOWN_RUN_CALLBACK_THREADSAFE, get_scheduled_timer_handles, @@ -640,6 +646,34 @@ def mock_registry( return registry +@attr.s(frozen=True, kw_only=True, slots=True) +class RegistryEntryWithDefaults(er.RegistryEntry): + """Helper to create a registry entry with defaults.""" + + capabilities: Mapping[str, Any] | None = attr.ib(default=None) + config_entry_id: str | None = attr.ib(default=None) + config_subentry_id: str | None = attr.ib(default=None) + created_at: datetime = attr.ib(factory=dt_util.utcnow) + device_id: str | None = attr.ib(default=None) + disabled_by: er.RegistryEntryDisabler | None = attr.ib(default=None) + entity_category: er.EntityCategory | None = attr.ib(default=None) + hidden_by: er.RegistryEntryHider | None = attr.ib(default=None) + id: str = attr.ib( + default=None, + converter=attr.converters.default_if_none(factory=uuid_util.random_uuid_hex), # type: ignore[misc] + ) + has_entity_name: bool = attr.ib(default=False) + options: er.ReadOnlyEntityOptionsType = attr.ib( + default=None, converter=er._protect_entity_options + ) + original_device_class: str | None = attr.ib(default=None) + original_icon: str | None = attr.ib(default=None) + original_name: str | None = attr.ib(default=None) + supported_features: int = attr.ib(default=0) + translation_key: str | None = attr.ib(default=None) + unit_of_measurement: str | None = attr.ib(default=None) + + def mock_area_registry( hass: HomeAssistant, mock_entries: dict[str, ar.AreaEntry] | None = None ) -> ar.AreaRegistry: @@ -1688,6 +1722,28 @@ def async_mock_cloud_connection_status(hass: HomeAssistant, connected: bool) -> async_dispatcher_send(hass, SIGNAL_CLOUD_CONNECTION_STATE, state) +@asynccontextmanager +async def async_call_logger_set_level( + logger: str, + level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "FATAL", "CRITICAL"], + *, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> AsyncGenerator[None]: + """Context manager to reset loggers after logger.set_level call.""" + assert LOGGER_DOMAIN in hass.data, "'logger' integration not setup" + with caplog.at_level(logging.NOTSET, logger): + await hass.services.async_call( + LOGGER_DOMAIN, + SERVICE_SET_LEVEL, + {logger: level}, + blocking=True, + ) + await hass.async_block_till_done() + yield + _clear_logger_overwrites(hass) + + def import_and_test_deprecated_constant_enum( caplog: pytest.LogCaptureFixture, module: ModuleType, @@ -1884,3 +1940,16 @@ def get_quality_scale(integration: str) -> dict[str, QualityScaleStatus]: ) for rule, details in raw["rules"].items() } + + +def get_schema_suggested_value(schema: vol.Schema, key: str) -> Any | None: + """Get suggested value for key in voluptuous schema.""" + for schema_key in schema: + if schema_key == key: + if ( + schema_key.description is None + or "suggested_value" not in schema_key.description + ): + return None + return schema_key.description["suggested_value"] + return None diff --git a/tests/components/adax/__init__.py b/tests/components/adax/__init__.py index 54a72856a85..60cc24b6dd0 100644 --- a/tests/components/adax/__init__.py +++ b/tests/components/adax/__init__.py @@ -1 +1,12 @@ """Tests for the Adax integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Set up the Adax integration in Home Assistant.""" + 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/adax/conftest.py b/tests/components/adax/conftest.py new file mode 100644 index 00000000000..64cbf96e9c4 --- /dev/null +++ b/tests/components/adax/conftest.py @@ -0,0 +1,89 @@ +"""Fixtures for Adax testing.""" + +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.adax.const import ( + ACCOUNT_ID, + CLOUD, + CONNECTION_TYPE, + DOMAIN, + LOCAL, +) +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_TOKEN, + CONF_UNIQUE_ID, +) + +from tests.common import AsyncMock, MockConfigEntry + +CLOUD_CONFIG = { + ACCOUNT_ID: 12345, + CONF_PASSWORD: "pswd", + CONNECTION_TYPE: CLOUD, +} + +LOCAL_CONFIG = { + CONF_IP_ADDRESS: "192.168.1.12", + CONF_TOKEN: "TOKEN-123", + CONF_UNIQUE_ID: "11:22:33:44:55:66", + CONNECTION_TYPE: LOCAL, +} + + +CLOUD_DEVICE_DATA: dict[str, Any] = [ + { + "id": "1", + "homeId": "1", + "name": "Room 1", + "temperature": 15, + "targetTemperature": 20, + "heatingEnabled": True, + } +] + +LOCAL_DEVICE_DATA: dict[str, Any] = { + "current_temperature": 15, + "target_temperature": 20, +} + + +@pytest.fixture +def mock_cloud_config_entry(request: pytest.FixtureRequest) -> MockConfigEntry: + """Mock a "CLOUD" config entry.""" + return MockConfigEntry(domain=DOMAIN, data=CLOUD_CONFIG) + + +@pytest.fixture +def mock_local_config_entry(request: pytest.FixtureRequest) -> MockConfigEntry: + """Mock a "LOCAL" config entry.""" + return MockConfigEntry(domain=DOMAIN, data=LOCAL_CONFIG) + + +@pytest.fixture +def mock_adax_cloud(): + """Mock climate data.""" + with patch("homeassistant.components.adax.coordinator.Adax") as mock_adax: + mock_adax_class = mock_adax.return_value + + mock_adax_class.get_rooms = AsyncMock() + mock_adax_class.get_rooms.return_value = CLOUD_DEVICE_DATA + + mock_adax_class.update = AsyncMock() + mock_adax_class.update.return_value = None + yield mock_adax_class + + +@pytest.fixture +def mock_adax_local(): + """Mock climate data.""" + with patch("homeassistant.components.adax.coordinator.AdaxLocal") as mock_adax: + mock_adax_class = mock_adax.return_value + + mock_adax_class.get_status = AsyncMock() + mock_adax_class.get_status.return_value = LOCAL_DEVICE_DATA + yield mock_adax_class diff --git a/tests/components/adax/test_climate.py b/tests/components/adax/test_climate.py new file mode 100644 index 00000000000..dd5cc3ff387 --- /dev/null +++ b/tests/components/adax/test_climate.py @@ -0,0 +1,85 @@ +"""Test Adax climate entity.""" + +from homeassistant.components.adax.const import SCAN_INTERVAL +from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE, HVACMode +from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import CLOUD_DEVICE_DATA, LOCAL_DEVICE_DATA + +from tests.common import AsyncMock, MockConfigEntry, async_fire_time_changed +from tests.test_setup import FrozenDateTimeFactory + + +async def test_climate_cloud( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_cloud_config_entry: MockConfigEntry, + mock_adax_cloud: AsyncMock, +) -> None: + """Test states of the (cloud) Climate entity.""" + await setup_integration(hass, mock_cloud_config_entry) + mock_adax_cloud.get_rooms.assert_called_once() + + assert len(hass.states.async_entity_ids(Platform.CLIMATE)) == 1 + entity_id = hass.states.async_entity_ids(Platform.CLIMATE)[0] + + state = hass.states.get(entity_id) + + assert state + assert state.state == HVACMode.HEAT + assert ( + state.attributes[ATTR_TEMPERATURE] == CLOUD_DEVICE_DATA[0]["targetTemperature"] + ) + assert ( + state.attributes[ATTR_CURRENT_TEMPERATURE] + == CLOUD_DEVICE_DATA[0]["temperature"] + ) + + mock_adax_cloud.get_rooms.side_effect = Exception() + freezer.tick(SCAN_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 + + +async def test_climate_local( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_local_config_entry: MockConfigEntry, + mock_adax_local: AsyncMock, +) -> None: + """Test states of the (local) Climate entity.""" + await setup_integration(hass, mock_local_config_entry) + mock_adax_local.get_status.assert_called_once() + + assert len(hass.states.async_entity_ids(Platform.CLIMATE)) == 1 + entity_id = hass.states.async_entity_ids(Platform.CLIMATE)[0] + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == HVACMode.HEAT + assert ( + state.attributes[ATTR_TEMPERATURE] == (LOCAL_DEVICE_DATA["target_temperature"]) + ) + assert ( + state.attributes[ATTR_CURRENT_TEMPERATURE] + == (LOCAL_DEVICE_DATA["current_temperature"]) + ) + + mock_adax_local.get_status.side_effect = Exception() + freezer.tick(SCAN_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 diff --git a/tests/components/airzone/snapshots/test_sensor.ambr b/tests/components/airzone/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..01ebf35b282 --- /dev/null +++ b/tests/components/airzone/snapshots/test_sensor.ambr @@ -0,0 +1,1245 @@ +# serializer version: 1 +# name: test_airzone_create_sensors[sensor.airzone_2_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airzone_2_1_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_2:1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_2_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Airzone 2:1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.airzone_2_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '62', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_2_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airzone_2_1_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_2:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_2_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Airzone 2:1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airzone_2_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.3', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_dhw_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airzone_dhw_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_dhw_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_dhw_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Airzone DHW Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airzone_dhw_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_webserver_rssi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.airzone_webserver_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RSSI', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rssi', + 'unique_id': 'airzone_unique_id_ws_wifi-rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_webserver_rssi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Airzone WebServer RSSI', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.airzone_webserver_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-42', + }) +# --- +# name: test_airzone_create_sensors[sensor.aux_heat_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aux_heat_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_4:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.aux_heat_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Aux Heat Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aux_heat_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.despacho_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:4_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Despacho Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.despacho_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.despacho_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:4_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Despacho Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.despacho_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.despacho_signal_strength', + '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': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:4_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Despacho Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.despacho_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.despacho_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:4_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Despacho Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.despacho_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.20', + }) +# --- +# name: test_airzone_create_sensors[sensor.dkn_plus_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dkn_plus_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_3:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dkn_plus_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'DKN Plus Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dkn_plus_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.7', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_1_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:3_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dorm #1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_1_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:3_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dorm #1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dorm_1_signal_strength', + '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': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:3_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dorm #1 Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_1_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_1_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:3_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Dorm #1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dorm_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.8', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_2_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:5_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dorm #2 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_2_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_2_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:5_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dorm #2 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_2_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dorm_2_signal_strength', + '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': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:5_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dorm #2 Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_2_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '66', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_2_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:5_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Dorm #2 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dorm_2_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.5', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_ppal_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:2_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dorm Ppal Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_ppal_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:2_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dorm Ppal Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dorm_ppal_signal_strength', + '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': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:2_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dorm Ppal Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '72', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_ppal_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:2_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Dorm Ppal Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.1', + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.salon_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Salon Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.salon_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34', + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.salon_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Salon Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.salon_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.6', + }) +# --- diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index 352994d6313..b226be8ac78 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -1,14 +1,17 @@ """The sensor tests for the Airzone platform.""" +from collections.abc import Generator import copy from unittest.mock import patch from aioairzone.const import API_DATA, API_SYSTEMS import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.airzone.coordinator import SCAN_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow from .util import ( @@ -20,62 +23,27 @@ from .util import ( async_init_integration, ) -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.airzone.PLATFORMS", [Platform.SENSOR]): + yield @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_airzone_create_sensors(hass: HomeAssistant) -> None: +async def test_airzone_create_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: """Test creation of sensors.""" - await async_init_integration(hass) + config_entry = await async_init_integration(hass) - # Hot Water - state = hass.states.get("sensor.airzone_dhw_temperature") - assert state.state == "43" - - # WebServer - state = hass.states.get("sensor.airzone_webserver_rssi") - assert state.state == "-42" - - # Zones - state = hass.states.get("sensor.despacho_temperature") - assert state.state == "21.20" - - state = hass.states.get("sensor.despacho_humidity") - assert state.state == "36" - - state = hass.states.get("sensor.dorm_1_temperature") - assert state.state == "20.8" - - state = hass.states.get("sensor.dorm_1_humidity") - assert state.state == "35" - - state = hass.states.get("sensor.dorm_2_temperature") - assert state.state == "20.5" - - state = hass.states.get("sensor.dorm_2_humidity") - assert state.state == "40" - - state = hass.states.get("sensor.dorm_ppal_temperature") - assert state.state == "21.1" - - state = hass.states.get("sensor.dorm_ppal_humidity") - assert state.state == "39" - - state = hass.states.get("sensor.salon_temperature") - assert state.state == "19.6" - - state = hass.states.get("sensor.salon_humidity") - assert state.state == "34" - - state = hass.states.get("sensor.airzone_2_1_temperature") - assert state.state == "22.3" - - state = hass.states.get("sensor.airzone_2_1_humidity") - assert state.state == "62" - - state = hass.states.get("sensor.dkn_plus_temperature") - assert state.state == "21.7" + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) state = hass.states.get("sensor.dkn_plus_humidity") assert state is None diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 944ca83d053..55cb32b67a5 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -371,7 +371,7 @@ HVAC_WEBSERVER_MOCK = { async def async_init_integration( hass: HomeAssistant, -) -> None: +) -> MockConfigEntry: """Set up the Airzone integration in Home Assistant.""" config_entry = MockConfigEntry( @@ -407,3 +407,5 @@ async def async_init_integration( ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py index e76ed4ba6d0..c0f206ee4e2 100644 --- a/tests/components/alexa/test_flash_briefings.py +++ b/tests/components/alexa/test_flash_briefings.py @@ -1,6 +1,5 @@ """The tests for the Alexa component.""" -from asyncio import AbstractEventLoop import datetime from http import HTTPStatus @@ -24,13 +23,11 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @pytest.fixture -def alexa_client( - event_loop: AbstractEventLoop, +async def alexa_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Initialize a Home Assistant server for testing this module.""" - loop = event_loop @callback def mock_service(call): @@ -38,38 +35,36 @@ def alexa_client( hass.services.async_register("test", "alexa", mock_service) - assert loop.run_until_complete( - async_setup_component( - hass, - alexa.DOMAIN, - { - # Key is here to verify we allow other keys in config too - "homeassistant": {}, - "alexa": { - "flash_briefings": { - "password": "pass/abc", - "weather": [ - { - "title": "Weekly forecast", - "text": "This week it will be sunny.", - }, - { - "title": "Current conditions", - "text": "Currently it is 80 degrees fahrenheit.", - }, - ], - "news_audio": { - "title": "NPR", - "audio": NPR_NEWS_MP3_URL, - "display_url": "https://npr.org", - "uid": "uuid", + assert await async_setup_component( + hass, + alexa.DOMAIN, + { + # Key is here to verify we allow other keys in config too + "homeassistant": {}, + "alexa": { + "flash_briefings": { + "password": "pass/abc", + "weather": [ + { + "title": "Weekly forecast", + "text": "This week it will be sunny.", }, - } - }, + { + "title": "Current conditions", + "text": "Currently it is 80 degrees fahrenheit.", + }, + ], + "news_audio": { + "title": "NPR", + "audio": NPR_NEWS_MP3_URL, + "display_url": "https://npr.org", + "uid": "uuid", + }, + } }, - ) + }, ) - return loop.run_until_complete(hass_client()) + return await hass_client() def _flash_briefing_req(client, briefing_id, password="pass%2Fabc"): diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index b82048dca9b..9c9a292c456 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -1,6 +1,5 @@ """The tests for the Alexa component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus import json @@ -30,13 +29,11 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @pytest.fixture -def alexa_client( - event_loop: AbstractEventLoop, +async def alexa_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Initialize a Home Assistant server for testing this module.""" - loop = event_loop @callback def mock_service(call): @@ -44,96 +41,92 @@ def alexa_client( hass.services.async_register("test", "alexa", mock_service) - assert loop.run_until_complete( - async_setup_component( - hass, - alexa.DOMAIN, - { - # Key is here to verify we allow other keys in config too - "homeassistant": {}, - "alexa": {}, - }, - ) + assert await async_setup_component( + hass, + alexa.DOMAIN, + { + # Key is here to verify we allow other keys in config too + "homeassistant": {}, + "alexa": {}, + }, ) - assert loop.run_until_complete( - async_setup_component( - hass, - "intent_script", - { - "intent_script": { - "WhereAreWeIntent": { - "speech": { - "type": "plain", - "text": """ - {%- if is_state("device_tracker.paulus", "home") - and is_state("device_tracker.anne_therese", - "home") -%} - You are both home, you silly - {%- else -%} - Anne Therese is at {{ - states("device_tracker.anne_therese") - }} and Paulus is at {{ - states("device_tracker.paulus") - }} - {% endif %} - """, - } + assert await async_setup_component( + hass, + "intent_script", + { + "intent_script": { + "WhereAreWeIntent": { + "speech": { + "type": "plain", + "text": """ + {%- if is_state("device_tracker.paulus", "home") + and is_state("device_tracker.anne_therese", + "home") -%} + You are both home, you silly + {%- else -%} + Anne Therese is at {{ + states("device_tracker.anne_therese") + }} and Paulus is at {{ + states("device_tracker.paulus") + }} + {% endif %} + """, + } + }, + "GetZodiacHoroscopeIntent": { + "speech": { + "type": "plain", + "text": "You told us your sign is {{ ZodiacSign }}.", + } + }, + "GetZodiacHoroscopeIDIntent": { + "speech": { + "type": "plain", + "text": "You told us your sign is {{ ZodiacSign_Id }}.", + } + }, + "AMAZON.PlaybackAction": { + "speech": { + "type": "plain", + "text": "Playing {{ object_byArtist_name }}.", + } + }, + "CallServiceIntent": { + "speech": { + "type": "plain", + "text": "Service called for {{ ZodiacSign }}", }, - "GetZodiacHoroscopeIntent": { - "speech": { - "type": "plain", - "text": "You told us your sign is {{ ZodiacSign }}.", - } + "card": { + "type": "simple", + "title": "Card title for {{ ZodiacSign }}", + "content": "Card content: {{ ZodiacSign }}", }, - "GetZodiacHoroscopeIDIntent": { - "speech": { - "type": "plain", - "text": "You told us your sign is {{ ZodiacSign_Id }}.", - } + "action": { + "service": "test.alexa", + "data_template": {"hello": "{{ ZodiacSign }}"}, + "entity_id": "switch.test", }, - "AMAZON.PlaybackAction": { - "speech": { - "type": "plain", - "text": "Playing {{ object_byArtist_name }}.", - } + }, + APPLICATION_ID: { + "speech": { + "type": "plain", + "text": "LaunchRequest has been received.", + } + }, + APPLICATION_ID_SESSION_OPEN: { + "speech": { + "type": "plain", + "text": "LaunchRequest has been received.", }, - "CallServiceIntent": { - "speech": { - "type": "plain", - "text": "Service called for {{ ZodiacSign }}", - }, - "card": { - "type": "simple", - "title": "Card title for {{ ZodiacSign }}", - "content": "Card content: {{ ZodiacSign }}", - }, - "action": { - "service": "test.alexa", - "data_template": {"hello": "{{ ZodiacSign }}"}, - "entity_id": "switch.test", - }, + "reprompt": { + "type": "plain", + "text": "LaunchRequest has been received.", }, - APPLICATION_ID: { - "speech": { - "type": "plain", - "text": "LaunchRequest has been received.", - } - }, - APPLICATION_ID_SESSION_OPEN: { - "speech": { - "type": "plain", - "text": "LaunchRequest has been received.", - }, - "reprompt": { - "type": "plain", - "text": "LaunchRequest has been received.", - }, - }, - } - }, - ) + }, + } + }, ) - return loop.run_until_complete(hass_client()) + return await hass_client() def _intent_req(client, data=None): diff --git a/tests/components/androidtv_remote/test_media_player.py b/tests/components/androidtv_remote/test_media_player.py index e292a5b273f..2af8aeb2f56 100644 --- a/tests/components/androidtv_remote/test_media_player.py +++ b/tests/components/androidtv_remote/test_media_player.py @@ -355,6 +355,7 @@ async def test_browse_media( "children_media_class": "app", "can_play": False, "can_expand": True, + "can_search": False, "thumbnail": None, "not_shown": 0, "children": [ @@ -366,6 +367,7 @@ async def test_browse_media( "children_media_class": None, "can_play": False, "can_expand": False, + "can_search": False, "thumbnail": "https://www.youtube.com/icon.png", }, { @@ -376,6 +378,7 @@ async def test_browse_media( "children_media_class": None, "can_play": False, "can_expand": False, + "can_search": False, "thumbnail": "", }, ], @@ -391,7 +394,9 @@ async def test_media_player_connection_closed( assert mock_config_entry.state is ConfigEntryState.LOADED mock_api.send_key_command.side_effect = ConnectionClosed() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="Connection to the Android TV device is closed" + ): await hass.services.async_call( "media_player", "media_pause", @@ -400,7 +405,9 @@ async def test_media_player_connection_closed( ) mock_api.send_launch_app_command.side_effect = ConnectionClosed() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="Connection to the Android TV device is closed" + ): await hass.services.async_call( "media_player", "play_media", diff --git a/tests/components/androidtv_remote/test_remote.py b/tests/components/androidtv_remote/test_remote.py index b3c3ce1c283..9bd86bb3d85 100644 --- a/tests/components/androidtv_remote/test_remote.py +++ b/tests/components/androidtv_remote/test_remote.py @@ -183,7 +183,9 @@ async def test_remote_connection_closed( assert mock_config_entry.state is ConfigEntryState.LOADED mock_api.send_key_command.side_effect = ConnectionClosed() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="Connection to the Android TV device is closed" + ): await hass.services.async_call( "remote", "send_command", @@ -197,7 +199,9 @@ async def test_remote_connection_closed( assert mock_api.send_key_command.mock_calls == [call("DPAD_LEFT", "SHORT")] mock_api.send_launch_app_command.side_effect = ConnectionClosed() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="Connection to the Android TV device is closed" + ): await hass.services.async_call( "remote", "turn_on", diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index c0ed986f002..ea4ce5a980d 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -3,11 +3,11 @@ list([ dict({ 'content': ''' - Current time is 16:00:00. Today's date is 2024-06-03. You are a voice assistant for Home Assistant. Answer questions about the world truthfully. Answer in plain text. Keep it simple and to the point. Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. + Current time is 16:00:00. Today's date is 2024-06-03. ''', 'role': 'system', }), diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index 30aba6e1b1f..1f41b7df2c7 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -196,13 +196,13 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ( { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "none", CONF_PROMPT: "bla", }, { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_TEMPERATURE: 0.3, + CONF_LLM_HASS_API: [], }, { CONF_RECOMMENDED: False, @@ -224,15 +224,32 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non }, { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, ), + ( + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: "assist", + }, + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: ["assist"], + }, + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: ["assist"], + }, + ), ], ) async def test_options_switching( diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index caaef43e931..8706abf36c0 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -8,9 +8,11 @@ from anthropic import RateLimitError from anthropic.types import ( InputJSONDelta, Message, + MessageDeltaUsage, RawContentBlockDeltaEvent, RawContentBlockStartEvent, RawContentBlockStopEvent, + RawMessageDeltaEvent, RawMessageStartEvent, RawMessageStopEvent, RawMessageStreamEvent, @@ -23,6 +25,7 @@ from anthropic.types import ( ToolUseBlock, Usage, ) +from anthropic.types.raw_message_delta_event import Delta from freezegun import freeze_time from httpx import URL, Request, Response import pytest @@ -65,6 +68,11 @@ def create_messages( type="message_start", ), *content_blocks, + RawMessageDeltaEvent( + type="message_delta", + delta=Delta(stop_reason="end_turn", stop_sequence=""), + usage=MessageDeltaUsage(output_tokens=0), + ), RawMessageStopEvent(type="message_stop"), ] diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index eb8cd594ad7..5994a7f4c17 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -1,10 +1,13 @@ """Tests for the APCUPSd component.""" +from __future__ import annotations + from collections import OrderedDict from typing import Final from unittest.mock import patch from homeassistant.components.apcupsd.const import DOMAIN +from homeassistant.components.apcupsd.coordinator import APCUPSdData from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant @@ -79,7 +82,7 @@ MOCK_MINIMAL_STATUS: Final = OrderedDict( async def async_init_integration( - hass: HomeAssistant, host: str = "test", status=None + hass: HomeAssistant, host: str = "test", status: dict[str, str] | None = None ) -> MockConfigEntry: """Set up the APC UPS Daemon integration in HomeAssistant.""" if status is None: @@ -90,7 +93,7 @@ async def async_init_integration( domain=DOMAIN, title="APCUPSd", data=CONF_DATA | {CONF_HOST: host}, - unique_id=status.get("SERIALNO", None), + unique_id=APCUPSdData(status).serial_no, source=SOURCE_USER, ) diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 6bb94ca2948..9edf4d8282f 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -28,8 +28,8 @@ from tests.common import MockConfigEntry, async_fire_time_changed # Contains "SERIALNO" but no "UPSNAME" field. # We should create devices for the entities and prefix their IDs with default "APC UPS". MOCK_MINIMAL_STATUS | {"SERIALNO": "XXXX"}, - # Does not contain either "SERIALNO" field. - # We should _not_ create devices for the entities and their IDs will not have prefixes. + # Does not contain either "SERIALNO" field or "UPSNAME" field. Our integration should work + # fine without it by falling back to config entry ID as unique ID and "APC UPS" as default name. MOCK_MINIMAL_STATUS, # Some models report "Blank" as SERIALNO, but we should treat it as not reported. MOCK_MINIMAL_STATUS | {"SERIALNO": "Blank"}, @@ -37,14 +37,9 @@ from tests.common import MockConfigEntry, async_fire_time_changed ) async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> None: """Test a successful setup entry.""" - # Minimal status does not contain "SERIALNO" field, which is used to determine the - # unique ID of this integration. But, the integration should work fine without it. - # In such a case, the device will not be added either await async_init_integration(hass, status=status) - prefix = "" - if "SERIALNO" in status and status["SERIALNO"] != "Blank": - prefix = slugify(status.get("UPSNAME", "APC UPS")) + "_" + prefix = slugify(status.get("UPSNAME", "APC UPS")) + "_" # Verify successful setup by querying the status sensor. state = hass.states.get(f"binary_sensor.{prefix}online_status") @@ -72,15 +67,13 @@ async def test_device_entry( hass: HomeAssistant, status: OrderedDict, device_registry: dr.DeviceRegistry ) -> None: """Test successful setup of device entries.""" - await async_init_integration(hass, status=status) + config_entry = await async_init_integration(hass, status=status) # Verify device info is properly set up. - if "SERIALNO" not in status or status["SERIALNO"] == "Blank": - assert len(device_registry.devices) == 0 - return - assert len(device_registry.devices) == 1 - entry = device_registry.async_get_device({(DOMAIN, status["SERIALNO"])}) + entry = device_registry.async_get_device( + {(DOMAIN, config_entry.unique_id or config_entry.entry_id)} + ) assert entry is not None # Specify the mapping between field name and the expected fields in device entry. fields = { diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index 0fe7f12ad27..f36421c4183 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -244,11 +244,14 @@ async def test_sensor_unknown(hass: HomeAssistant) -> None: """Test if our integration can properly certain sensors as unknown when it becomes so.""" await async_init_integration(hass, status=MOCK_MINIMAL_STATUS) - assert hass.states.get("sensor.mode").state == MOCK_MINIMAL_STATUS["UPSMODE"] + ups_mode_id = "sensor.apc_ups_mode" + last_self_test_id = "sensor.apc_ups_last_self_test" + + assert hass.states.get(ups_mode_id).state == MOCK_MINIMAL_STATUS["UPSMODE"] # Last self test sensor should be added even if our status does not report it initially (it is # a sensor that appears only after a periodical or manual self test is performed). - assert hass.states.get("sensor.last_self_test") is not None - assert hass.states.get("sensor.last_self_test").state == STATE_UNKNOWN + assert hass.states.get(last_self_test_id) is not None + assert hass.states.get(last_self_test_id).state == STATE_UNKNOWN # Simulate an event (a self test) such that "LASTSTEST" field is being reported, the state of # the sensor should be properly updated with the corresponding value. @@ -259,7 +262,7 @@ async def test_sensor_unknown(hass: HomeAssistant) -> None: future = utcnow() + timedelta(minutes=2) async_fire_time_changed(hass, future) await hass.async_block_till_done() - assert hass.states.get("sensor.last_self_test").state == "1970-01-01 00:00:00 0000" + assert hass.states.get(last_self_test_id).state == "1970-01-01 00:00:00 0000" # Simulate another event (e.g., daemon restart) such that "LASTSTEST" is no longer reported. with patch("aioapcaccess.request_status") as mock_request_status: @@ -268,4 +271,4 @@ async def test_sensor_unknown(hass: HomeAssistant) -> None: async_fire_time_changed(hass, future) await hass.async_block_till_done() # The state should become unknown again. - assert hass.states.get("sensor.last_self_test").state == STATE_UNKNOWN + assert hass.states.get(last_self_test_id).state == STATE_UNKNOWN diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 6363304effc..26a3d7c7a8c 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -22,12 +22,12 @@ from tests.typing import ClientSessionGenerator @pytest.fixture -def mock_api_client( +async def mock_api_client( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> TestClient: """Start the Home Assistant HTTP component and return admin API client.""" - hass.loop.run_until_complete(async_setup_component(hass, "api", {})) - return hass.loop.run_until_complete(hass_client()) + await async_setup_component(hass, "api", {}) + return await hass_client() async def test_api_list_state_entities( diff --git a/tests/components/apsystems/conftest.py b/tests/components/apsystems/conftest.py index 92af6885c0b..d1c97e991a8 100644 --- a/tests/components/apsystems/conftest.py +++ b/tests/components/apsystems/conftest.py @@ -43,6 +43,7 @@ def mock_apsystems() -> Generator[MagicMock]: ipAddr="127.0.01", minPower=0, maxPower=1000, + isBatterySystem=False, ) mock_api.get_output_data.return_value = ReturnOutputData( p1=2.0, diff --git a/tests/components/apsystems/snapshots/test_binary_sensor.ambr b/tests/components/apsystems/snapshots/test_binary_sensor.ambr index 381fc1864fc..d2e73347c83 100644 --- a/tests/components/apsystems/snapshots/test_binary_sensor.ambr +++ b/tests/components/apsystems/snapshots/test_binary_sensor.ambr @@ -120,7 +120,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Off grid status', + 'original_name': 'Off-grid status', 'platform': 'apsystems', 'previous_unique_id': None, 'supported_features': 0, @@ -133,7 +133,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Mock Title Off grid status', + 'friendly_name': 'Mock Title Off-grid status', }), 'context': , 'entity_id': 'binary_sensor.mock_title_off_grid_status', diff --git a/tests/components/aws_s3/__init__.py b/tests/components/aws_s3/__init__.py new file mode 100644 index 00000000000..90e4652bb2b --- /dev/null +++ b/tests/components/aws_s3/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the AWS S3 integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the S3 integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/aws_s3/conftest.py b/tests/components/aws_s3/conftest.py new file mode 100644 index 00000000000..8f12ee17661 --- /dev/null +++ b/tests/components/aws_s3/conftest.py @@ -0,0 +1,82 @@ +"""Common fixtures for the AWS S3 tests.""" + +from collections.abc import AsyncIterator, Generator +import json +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.aws_s3.backup import ( + MULTIPART_MIN_PART_SIZE_BYTES, + suggested_filenames, +) +from homeassistant.components.aws_s3.const import DOMAIN +from homeassistant.components.backup import AgentBackup + +from .const import USER_INPUT + +from tests.common import MockConfigEntry + + +@pytest.fixture( + params=[2**20, MULTIPART_MIN_PART_SIZE_BYTES], + ids=["small", "large"], +) +def test_backup(request: pytest.FixtureRequest) -> None: + """Test backup fixture.""" + return AgentBackup( + addons=[], + backup_id="23e64aec", + date="2024-11-22T11:48:48.727189+01:00", + database_included=True, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="Core 2024.12.0.dev0", + protected=False, + size=request.param, + ) + + +@pytest.fixture(autouse=True) +def mock_client(test_backup: AgentBackup) -> Generator[AsyncMock]: + """Mock the S3 client.""" + with patch( + "aiobotocore.session.AioSession.create_client", + autospec=True, + return_value=AsyncMock(), + ) as create_client: + client = create_client.return_value + + tar_file, metadata_file = suggested_filenames(test_backup) + client.list_objects_v2.return_value = { + "Contents": [{"Key": tar_file}, {"Key": metadata_file}] + } + client.create_multipart_upload.return_value = {"UploadId": "upload_id"} + client.upload_part.return_value = {"ETag": "etag"} + + # to simplify this mock, we assume that backup is always "iterated" over, while metadata is always "read" as a whole + class MockStream: + async def iter_chunks(self) -> AsyncIterator[bytes]: + yield b"backup data" + + async def read(self) -> bytes: + return json.dumps(test_backup.as_dict()).encode() + + client.get_object.return_value = {"Body": MockStream()} + client.head_bucket.return_value = {} + + create_client.return_value.__aenter__.return_value = client + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + entry_id="test", + title="test", + domain=DOMAIN, + data=USER_INPUT, + ) diff --git a/tests/components/aws_s3/const.py b/tests/components/aws_s3/const.py new file mode 100644 index 00000000000..ebffa11d956 --- /dev/null +++ b/tests/components/aws_s3/const.py @@ -0,0 +1,15 @@ +"""Consts for AWS S3 tests.""" + +from homeassistant.components.aws_s3.const import ( + CONF_ACCESS_KEY_ID, + CONF_BUCKET, + CONF_ENDPOINT_URL, + CONF_SECRET_ACCESS_KEY, +) + +USER_INPUT = { + CONF_ACCESS_KEY_ID: "TestTestTestTestTest", + CONF_SECRET_ACCESS_KEY: "TestTestTestTestTestTestTestTestTestTest", + CONF_ENDPOINT_URL: "https://s3.eu-south-1.amazonaws.com", + CONF_BUCKET: "test", +} diff --git a/tests/components/aws_s3/test_backup.py b/tests/components/aws_s3/test_backup.py new file mode 100644 index 00000000000..a8b24ec1ab4 --- /dev/null +++ b/tests/components/aws_s3/test_backup.py @@ -0,0 +1,470 @@ +"""Test the AWS S3 backup platform.""" + +from collections.abc import AsyncGenerator +from io import StringIO +import json +from time import time +from unittest.mock import AsyncMock, Mock, patch + +from botocore.exceptions import ConnectTimeoutError +import pytest + +from homeassistant.components.aws_s3.backup import ( + MULTIPART_MIN_PART_SIZE_BYTES, + BotoCoreError, + S3BackupAgent, + async_register_backup_agents_listener, + suggested_filenames, +) +from homeassistant.components.aws_s3.const import ( + CONF_ENDPOINT_URL, + DATA_BACKUP_AGENT_LISTENERS, + DOMAIN, +) +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup +from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup +from homeassistant.setup import async_setup_component + +from . import setup_integration +from .const import USER_INPUT + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_backup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> AsyncGenerator[None]: + """Set up S3 integration.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): + async_initialize_backup(hass) + assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + await setup_integration(hass, mock_config_entry) + + await hass.async_block_till_done() + yield + + +async def test_suggested_filenames() -> None: + """Test the suggested_filenames function.""" + backup = AgentBackup( + backup_id="a1b2c3", + date="2021-01-01T01:02:03+00:00", + addons=[], + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=False, + homeassistant_version=None, + name="my_pretty_backup", + protected=False, + size=0, + ) + tar_filename, metadata_filename = suggested_filenames(backup) + + assert tar_filename == "my_pretty_backup_2021-01-01_01.02_03000000.tar" + assert ( + metadata_filename == "my_pretty_backup_2021-01-01_01.02_03000000.metadata.json" + ) + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + { + "agent_id": f"{DOMAIN}.{mock_config_entry.entry_id}", + "name": mock_config_entry.title, + }, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, + test_backup: AgentBackup, +) -> None: + """Test agent list backups.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": test_backup.addons, + "backup_id": test_backup.backup_id, + "date": test_backup.date, + "database_included": test_backup.database_included, + "folders": test_backup.folders, + "homeassistant_included": test_backup.homeassistant_included, + "homeassistant_version": test_backup.homeassistant_version, + "name": test_backup.name, + "extra_metadata": test_backup.extra_metadata, + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": test_backup.protected, + "size": test_backup.size, + } + }, + "failed_agent_ids": [], + "with_automatic_settings": None, + } + ] + + +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, + test_backup: AgentBackup, +) -> None: + """Test agent get backup.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "backup/details", "backup_id": test_backup.backup_id} + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == { + "addons": test_backup.addons, + "backup_id": test_backup.backup_id, + "date": test_backup.date, + "database_included": test_backup.database_included, + "folders": test_backup.folders, + "homeassistant_included": test_backup.homeassistant_included, + "homeassistant_version": test_backup.homeassistant_version, + "name": test_backup.name, + "extra_metadata": test_backup.extra_metadata, + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": test_backup.protected, + "size": test_backup.size, + } + }, + "failed_agent_ids": [], + "with_automatic_settings": None, + } + + +async def test_agents_get_backup_does_not_throw_on_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, +) -> None: + """Test agent get backup does not throw on a backup not found.""" + mock_client.list_objects_v2.return_value = {"Contents": []} + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": "random"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] is None + + +async def test_agents_list_backups_with_corrupted_metadata( + hass: HomeAssistant, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + test_backup: AgentBackup, +) -> None: + """Test listing backups when one metadata file is corrupted.""" + # Create agent + agent = S3BackupAgent(hass, mock_config_entry) + + # Set up mock responses for both valid and corrupted metadata files + mock_client.list_objects_v2.return_value = { + "Contents": [ + { + "Key": "valid_backup.metadata.json", + "LastModified": "2023-01-01T00:00:00+00:00", + }, + { + "Key": "corrupted_backup.metadata.json", + "LastModified": "2023-01-01T00:00:00+00:00", + }, + ] + } + + # Mock responses for get_object calls + valid_metadata = json.dumps(test_backup.as_dict()) + corrupted_metadata = "{invalid json content" + + async def mock_get_object(**kwargs): + """Mock get_object with different responses based on the key.""" + key = kwargs.get("Key", "") + if "valid_backup" in key: + mock_body = AsyncMock() + mock_body.read.return_value = valid_metadata.encode() + return {"Body": mock_body} + # Corrupted metadata + mock_body = AsyncMock() + mock_body.read.return_value = corrupted_metadata.encode() + return {"Body": mock_body} + + mock_client.get_object.side_effect = mock_get_object + + backups = await agent.async_list_backups() + assert len(backups) == 1 + assert backups[0].backup_id == test_backup.backup_id + assert "Failed to process metadata file" in caplog.text + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": "23e64aec", + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + # Should delete both the tar and the metadata file + assert mock_client.delete_object.call_count == 2 + + +async def test_agents_delete_not_throwing_on_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, +) -> None: + """Test agent delete backup does not throw on a backup not found.""" + mock_client.list_objects_v2.return_value = {"Contents": []} + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": "random", + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + assert mock_client.delete_object.call_count == 0 + + +async def test_agents_upload( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, + test_backup: AgentBackup, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + return_value=test_backup, + ), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + # we must emit at least two chunks + # the "appendix" chunk triggers the upload of the final buffer part + mocked_open.return_value.read = Mock( + side_effect=[ + b"a" * test_backup.size, + b"appendix", + b"", + ] + ) + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + if test_backup.size < MULTIPART_MIN_PART_SIZE_BYTES: + # single part + metadata both as regular upload (no multiparts) + assert mock_client.create_multipart_upload.await_count == 0 + assert mock_client.put_object.await_count == 2 + else: + assert "Uploading final part" in caplog.text + # 2 parts as multipart + metadata as regular upload + assert mock_client.create_multipart_upload.await_count == 1 + assert mock_client.upload_part.await_count == 2 + assert mock_client.complete_multipart_upload.await_count == 1 + assert mock_client.put_object.await_count == 1 + + +async def test_agents_upload_network_failure( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, + test_backup: AgentBackup, +) -> None: + """Test agent upload backup with network failure.""" + client = await hass_client() + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + return_value=test_backup, + ), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + # simulate network failure + mock_client.put_object.side_effect = mock_client.upload_part.side_effect = ( + mock_client.abort_multipart_upload.side_effect + ) = ConnectTimeoutError(endpoint_url=USER_INPUT[CONF_ENDPOINT_URL]) + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert "Upload failed for aws_s3" in caplog.text + + +async def test_agents_download( + hass_client: ClientSessionGenerator, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + client = await hass_client() + backup_id = "23e64aec" + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + assert mock_client.get_object.call_count == 2 # One for metadata, one for tar file + + +async def test_error_during_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, + test_backup: AgentBackup, +) -> None: + """Test the error wrapper.""" + mock_client.delete_object.side_effect = BotoCoreError + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": test_backup.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": { + f"{DOMAIN}.{mock_config_entry.entry_id}": "Failed during async_delete_backup" + } + } + + +async def test_cache_expiration( + hass: HomeAssistant, + mock_client: MagicMock, + test_backup: AgentBackup, +) -> None: + """Test that the cache expires correctly.""" + # Mock the entry + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"bucket": "test-bucket"}, + unique_id="test-unique-id", + title="Test S3", + ) + mock_entry.runtime_data = mock_client + + # Create agent + agent = S3BackupAgent(hass, mock_entry) + + # Mock metadata response + metadata_content = json.dumps(test_backup.as_dict()) + mock_body = AsyncMock() + mock_body.read.return_value = metadata_content.encode() + mock_client.list_objects_v2.return_value = { + "Contents": [ + {"Key": "test.metadata.json", "LastModified": "2023-01-01T00:00:00+00:00"} + ] + } + + # First call should query S3 + await agent.async_list_backups() + assert mock_client.list_objects_v2.call_count == 1 + assert mock_client.get_object.call_count == 1 + + # Second call should use cache + await agent.async_list_backups() + assert mock_client.list_objects_v2.call_count == 1 + assert mock_client.get_object.call_count == 1 + + # Set cache to expire + agent._cache_expiration = time() - 1 + + # Third call should query S3 again + await agent.async_list_backups() + assert mock_client.list_objects_v2.call_count == 2 + assert mock_client.get_object.call_count == 2 + + +async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: + """Test listener gets cleaned up.""" + listener = MagicMock() + remove_listener = async_register_backup_agents_listener(hass, listener=listener) + + hass.data[DATA_BACKUP_AGENT_LISTENERS] = [ + listener + ] # make sure it's the last listener + remove_listener() + + assert DATA_BACKUP_AGENT_LISTENERS not in hass.data diff --git a/tests/components/aws_s3/test_config_flow.py b/tests/components/aws_s3/test_config_flow.py new file mode 100644 index 00000000000..593eea5cdb9 --- /dev/null +++ b/tests/components/aws_s3/test_config_flow.py @@ -0,0 +1,143 @@ +"""Test the AWS S3 config flow.""" + +from unittest.mock import AsyncMock, patch + +from botocore.exceptions import ( + ClientError, + EndpointConnectionError, + ParamValidationError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.aws_s3.const import CONF_BUCKET, CONF_ENDPOINT_URL, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import USER_INPUT + +from tests.common import MockConfigEntry + + +async def _async_start_flow( + hass: HomeAssistant, + user_input: dict[str, str] | None = None, +) -> FlowResultType: + """Initialize the config flow.""" + if user_input is None: + user_input = USER_INPUT + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + return await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + + +async def test_flow(hass: HomeAssistant) -> None: + """Test config flow.""" + result = await _async_start_flow(hass) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test" + assert result["data"] == USER_INPUT + + +@pytest.mark.parametrize( + ("exception", "errors"), + [ + ( + ParamValidationError(report="Invalid bucket name"), + {CONF_BUCKET: "invalid_bucket_name"}, + ), + (ValueError(), {CONF_ENDPOINT_URL: "invalid_endpoint_url"}), + ( + EndpointConnectionError(endpoint_url="http://example.com"), + {CONF_ENDPOINT_URL: "cannot_connect"}, + ), + ], +) +async def test_flow_create_client_errors( + hass: HomeAssistant, + exception: Exception, + errors: dict[str, str], +) -> None: + """Test config flow errors.""" + with patch( + "aiobotocore.session.AioSession.create_client", + side_effect=exception, + ): + result = await _async_start_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == errors + + # Fix and finish the test + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test" + assert result["data"] == USER_INPUT + + +async def test_flow_head_bucket_error( + hass: HomeAssistant, + mock_client: AsyncMock, +) -> None: + """Test setup_entry error when calling head_bucket.""" + mock_client.head_bucket.side_effect = ClientError( + error_response={"Error": {"Code": "InvalidAccessKeyId"}}, + operation_name="head_bucket", + ) + result = await _async_start_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_credentials"} + + # Fix and finish the test + mock_client.head_bucket.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"] == "test" + assert result["data"] == USER_INPUT + + +async def test_abort_if_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort if the account is already configured.""" + mock_config_entry.add_to_hass(hass) + result = await _async_start_flow(hass) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_create_not_aws_endpoint( + hass: HomeAssistant, +) -> None: + """Test config flow with a not aws endpoint should raise an error.""" + result = await _async_start_flow( + hass, USER_INPUT | {CONF_ENDPOINT_URL: "http://example.com"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_ENDPOINT_URL: "invalid_endpoint_url"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test" + assert result["data"] == USER_INPUT diff --git a/tests/components/aws_s3/test_init.py b/tests/components/aws_s3/test_init.py new file mode 100644 index 00000000000..ee247bfce1d --- /dev/null +++ b/tests/components/aws_s3/test_init.py @@ -0,0 +1,75 @@ +"""Test the AWS S3 storage integration.""" + +from unittest.mock import AsyncMock, patch + +from botocore.exceptions import ( + ClientError, + EndpointConnectionError, + ParamValidationError, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + 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.parametrize( + ("exception", "state"), + [ + ( + ParamValidationError(report="Invalid bucket name"), + ConfigEntryState.SETUP_ERROR, + ), + (ValueError(), ConfigEntryState.SETUP_ERROR), + ( + EndpointConnectionError(endpoint_url="https://example.com"), + ConfigEntryState.SETUP_RETRY, + ), + ], +) +async def test_setup_entry_create_client_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test various setup errors.""" + with patch( + "aiobotocore.session.AioSession.create_client", + side_effect=exception, + ): + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is state + + +async def test_setup_entry_head_bucket_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, +) -> None: + """Test setup_entry error when calling head_bucket.""" + mock_client.head_bucket.side_effect = ClientError( + error_response={"Error": {"Code": "InvalidAccessKeyId"}}, + operation_name="head_bucket", + ) + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/onboarding/snapshots/test_views.ambr b/tests/components/backup/snapshots/test_onboarding.ambr similarity index 100% rename from tests/components/onboarding/snapshots/test_views.ambr rename to tests/components/backup/snapshots/test_onboarding.ambr diff --git a/tests/components/backup/snapshots/test_sensors.ambr b/tests/components/backup/snapshots/test_sensors.ambr index 924038ef81f..be12afdbf1e 100644 --- a/tests/components/backup/snapshots/test_sensors.ambr +++ b/tests/components/backup/snapshots/test_sensors.ambr @@ -32,7 +32,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Backup Manager State', + 'original_name': 'Backup Manager state', 'platform': 'backup', 'previous_unique_id': None, 'supported_features': 0, @@ -45,7 +45,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', - 'friendly_name': 'Backup Backup Manager State', + 'friendly_name': 'Backup Backup Manager state', 'options': list([ 'idle', 'create_backup', diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index 41778322825..6f1bce8d5e4 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -40,7 +40,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -86,7 +86,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -131,7 +131,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -177,7 +177,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -196,6 +196,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -225,7 +226,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -244,6 +245,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -274,7 +276,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -293,6 +295,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -322,7 +325,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -341,6 +344,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -371,7 +375,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -390,6 +394,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': True, @@ -419,7 +424,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -438,6 +443,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': True, @@ -468,7 +474,112 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data5] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + }), + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': 'hunter2', + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 6, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data5].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + }), + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': 'hunter2', + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 6, 'version': 1, }) # --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 0bef632f0b4..7528785ab0d 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -300,6 +300,61 @@ 'type': 'result', }) # --- +# name: test_config_load_config_info[with_hassio-storage_data10] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + }), + 'test-agent2': dict({ + 'protected': False, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- # name: test_config_load_config_info[with_hassio-storage_data1] dict({ 'id': 1, @@ -556,9 +611,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': True, + 'retention': None, }), 'test-agent2': dict({ 'protected': False, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -714,6 +771,61 @@ 'type': 'result', }) # --- +# name: test_config_load_config_info[without_hassio-storage_data10] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + }), + 'test-agent2': dict({ + 'protected': False, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- # name: test_config_load_config_info[without_hassio-storage_data1] dict({ 'id': 1, @@ -966,9 +1078,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': True, + 'retention': None, }), 'test-agent2': dict({ 'protected': False, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -1198,7 +1312,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1315,7 +1429,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1432,7 +1546,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1482,9 +1596,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': True, + 'retention': None, }), 'test-agent2': dict({ 'protected': False, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -1527,9 +1643,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': True, + 'retention': None, }), 'test-agent2': dict({ 'protected': False, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -1559,7 +1677,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1609,9 +1727,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': True, + 'retention': None, }), 'test-agent2': dict({ 'protected': False, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -1653,9 +1773,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': False, + 'retention': None, }), 'test-agent2': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -1690,6 +1812,104 @@ }) # --- # name: test_config_update[commands13].3 + dict({ + 'id': 7, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': False, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + }), + 'test-agent2': dict({ + 'protected': True, + 'retention': None, + }), + }), + 'automatic_backups_configured': False, + '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, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands13].4 + dict({ + 'id': 9, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': False, + 'retention': None, + }), + 'test-agent2': dict({ + 'protected': True, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + }), + }), + 'automatic_backups_configured': False, + '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, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands13].5 dict({ 'data': dict({ 'backups': list([ @@ -1698,9 +1918,14 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': False, + 'retention': None, }), 'test-agent2': dict({ 'protected': True, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), }), }), 'automatic_backups_configured': False, @@ -1730,7 +1955,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1845,7 +2070,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1960,7 +2185,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2077,7 +2302,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2196,7 +2421,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2313,7 +2538,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2434,7 +2659,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2559,7 +2784,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2676,7 +2901,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2793,7 +3018,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2910,7 +3135,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -3027,7 +3252,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -3259,6 +3484,158 @@ 'type': 'result', }) # --- +# name: test_config_update_errors[command12] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + '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, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command12].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + '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, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command13] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + '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, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command13].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + '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, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- # name: test_config_update_errors[command1] dict({ 'id': 1, diff --git a/tests/components/backup/test_onboarding.py b/tests/components/backup/test_onboarding.py new file mode 100644 index 00000000000..7dfd57ec60a --- /dev/null +++ b/tests/components/backup/test_onboarding.py @@ -0,0 +1,414 @@ +"""Test the onboarding views.""" + +from io import StringIO +from typing import Any +from unittest.mock import ANY, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components import backup, onboarding +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.backup import async_initialize_backup +from homeassistant.setup import async_setup_component + +from tests.common import register_auth_provider +from tests.typing import ClientSessionGenerator + + +def mock_onboarding_storage(hass_storage, data): + """Mock the onboarding storage.""" + hass_storage[onboarding.STORAGE_KEY] = { + "version": onboarding.STORAGE_VERSION, + "data": data, + } + + +@pytest.fixture(autouse=True) +def auth_active(hass: HomeAssistant) -> None: + """Ensure auth is always active.""" + hass.loop.run_until_complete( + register_auth_provider(hass, {"type": "homeassistant"}) + ) + + +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_view_after_done( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], +) -> None: + """Test raising after onboarding.""" + mock_onboarding_storage(hass_storage, {"done": [onboarding.const.STEP_USER]}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) + + assert resp.status == 401 + + +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_backup_view_without_backup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], +) -> None: + """Test interacting with backup wievs when backup integration is missing.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) + + assert resp.status == 404 + + +async def test_onboarding_backup_info( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test backup info.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + backups = { + "abc123": backup.ManagerBackup( + addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")], + agents={ + "backup.local": backup.manager.AgentBackupStatus(protected=True, size=0) + }, + backup_id="abc123", + date="1970-01-01T00:00:00.000Z", + database_included=True, + extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + failed_agent_ids=[], + with_automatic_settings=True, + ), + "def456": backup.ManagerBackup( + addons=[], + agents={ + "test.remote": backup.manager.AgentBackupStatus(protected=True, size=0) + }, + backup_id="def456", + date="1980-01-01T00:00:00.000Z", + database_included=False, + extra_metadata={ + "instance_id": "unknown_uuid", + "with_automatic_settings": True, + }, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test 2", + failed_agent_ids=[], + with_automatic_settings=None, + ), + } + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ): + resp = await client.get("/api/onboarding/backup/info") + + assert resp.status == 200 + assert await resp.json() == snapshot + + +@pytest.mark.parametrize( + ("params", "expected_kwargs"), + [ + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + { + "agent_id": "backup.local", + "password": None, + "restore_addons": None, + "restore_database": True, + "restore_folders": None, + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": ["media"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": [backup.Folder.MEDIA], + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": ["media", "share"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": [backup.Folder.MEDIA, backup.Folder.SHARE], + "restore_homeassistant": True, + }, + ), + ], +) +async def test_onboarding_backup_restore( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + expected_kwargs: dict[str, Any], +) -> None: + """Test restore backup.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + assert resp.status == 200 + mock_restore.assert_called_once_with("abc123", **expected_kwargs) + + +@pytest.mark.parametrize( + ("params", "restore_error", "expected_status", "expected_json", "restore_calls"), + [ + # Missing agent_id + ( + {"backup_id": "abc123"}, + None, + 400, + { + "message": "Message format incorrect: required key not provided @ data['agent_id']" + }, + 0, + ), + # Missing backup_id + ( + {"agent_id": "backup.local"}, + None, + 400, + { + "message": "Message format incorrect: required key not provided @ data['backup_id']" + }, + 0, + ), + # Invalid restore_database + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_database": "yes_please", + }, + None, + 400, + { + "message": "Message format incorrect: expected bool for dictionary value @ data['restore_database']" + }, + 0, + ), + # Invalid folder + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_folders": ["invalid"], + }, + None, + 400, + { + "message": "Message format incorrect: expected Folder or one of 'share', 'addons/local', 'ssl', 'media' @ data['restore_folders'][0]" + }, + 0, + ), + # Wrong password + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + backup.IncorrectPasswordError, + 400, + {"code": "incorrect_password"}, + 1, + ), + # Home Assistant error + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + HomeAssistantError("Boom!"), + 400, + {"code": "restore_failed", "message": "Boom!"}, + 1, + ), + ], +) +async def test_onboarding_backup_restore_error( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + restore_error: Exception | None, + expected_status: int, + expected_json: str, + restore_calls: int, +) -> None: + """Test restore backup fails.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + side_effect=restore_error, + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + + assert resp.status == expected_status + assert await resp.json() == expected_json + assert len(mock_restore.mock_calls) == restore_calls + + +@pytest.mark.parametrize( + ("params", "restore_error", "expected_status", "expected_message", "restore_calls"), + [ + # Unexpected error + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + Exception("Boom!"), + 500, + "500 Internal Server Error", + 1, + ), + ], +) +async def test_onboarding_backup_restore_unexpected_error( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + restore_error: Exception | None, + expected_status: int, + expected_message: str, + restore_calls: int, +) -> None: + """Test restore backup fails.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + side_effect=restore_error, + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + + assert resp.status == expected_status + assert (await resp.content.read()).decode().startswith(expected_message) + assert len(mock_restore.mock_calls) == restore_calls + + +async def test_onboarding_backup_upload( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, +) -> None: + """Test upload backup.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + return_value="abc123", + ) as mock_receive: + resp = await client.post( + "/api/onboarding/backup/upload?agent_id=backup.local", + data={"file": StringIO("test")}, + ) + assert resp.status == 201 + assert await resp.json() == {"backup_id": "abc123"} + mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY) diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py index 0d29bb2006a..b078dcc2be7 100644 --- a/tests/components/backup/test_store.py +++ b/tests/components/backup/test_store.py @@ -98,7 +98,7 @@ def mock_delay_save() -> Generator[None]: } ], "config": { - "agents": {"test.remote": {"protected": True}}, + "agents": {"test.remote": {"protected": True, "retention": None}}, "automatic_backups_configured": False, "create_backup": { "agent_ids": [], @@ -200,6 +200,49 @@ def mock_delay_save() -> Generator[None]: "minor_version": 4, "version": 1, }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "agents": { + "test.remote": { + "protected": True, + "retention": {"copies": None, "days": None}, + } + }, + "automatic_backups_configured": True, + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": "hunter2", + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "minor_version": 6, + "version": 1, + }, ], ) async def test_store_migration( diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index d89e68f4ed8..e6a59142ca2 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -1,6 +1,7 @@ """Tests for the Backup integration.""" from collections.abc import Generator +from dataclasses import replace from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch @@ -9,6 +10,7 @@ import pytest from syrupy import SnapshotAssertion from homeassistant.components.backup import ( + AddonInfo, AgentBackup, BackupAgentError, BackupNotFound, @@ -81,6 +83,21 @@ DEFAULT_STORAGE_DATA: dict[str, Any] = { } DAILY = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] +TEST_MANAGER_BACKUP = ManagerBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + agents={"test.test-agent": AgentBackupStatus(protected=True, size=0)}, + backup_id="backup-1", + date="1970-01-01T00:00:00.000Z", + database_included=True, + extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + failed_agent_ids=[], + with_automatic_settings=True, +) + @pytest.fixture def sync_access_token_proxy( @@ -1160,8 +1177,8 @@ async def test_agents_info( "backups": [], "config": { "agents": { - "test-agent1": {"protected": True}, - "test-agent2": {"protected": False}, + "test-agent1": {"protected": True, "retention": None}, + "test-agent2": {"protected": False, "retention": None}, }, "automatic_backups_configured": False, "create_backup": { @@ -1253,6 +1270,47 @@ async def test_agents_info( "minor_version": store.STORAGE_VERSION_MINOR, }, }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": { + "test-agent1": { + "protected": True, + "retention": {"copies": 3, "days": None}, + }, + "test-agent2": { + "protected": False, + "retention": {"copies": None, "days": 7}, + }, + }, + "automatic_backups_configured": False, + "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": { + "days": ["mon", "sun"], + "recurrence": "custom_days", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, ], ) @pytest.mark.parametrize( @@ -1271,7 +1329,7 @@ async def test_config_load_config_info( snapshot: SnapshotAssertion, hass_storage: dict[str, Any], with_hassio: bool, - storage_data: dict[str, Any] | None, + storage_data: dict[str, Any], ) -> None: """Test loading stored backup config and reading it via config/info.""" client = await hass_ws_client(hass) @@ -1412,6 +1470,20 @@ async def test_config_load_config_info( "test-agent2": {"protected": True}, }, }, + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"retention": {"copies": 3}}, + "test-agent2": {"retention": None}, + }, + }, + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"retention": None}, + "test-agent2": {"retention": {"days": 7}}, + }, + }, ], [ { @@ -1433,7 +1505,7 @@ async def test_config_update( hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, - commands: dict[str, Any], + commands: list[dict[str, Any]], hass_storage: dict[str, Any], ) -> None: """Test updating the backup config.""" @@ -1522,6 +1594,14 @@ async def test_config_update( "type": "backup/config/update", "retention": {"days": 0}, }, + { + "type": "backup/config/update", + "agents": {"test-agent1": {"retention": {"copies": 0}}}, + }, + { + "type": "backup/config/update", + "agents": {"test-agent1": {"retention": {"days": 0}}}, + }, ], ) async def test_config_update_errors( @@ -2489,6 +2569,253 @@ async def test_config_schedule_logic( 1, {}, ), + ( + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": None, + }, + "test.test-agent2": { + "protected": True, + "retention": { + "copies": 1, + "days": None, + }, + }, + }, + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-5": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-5", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + { + "test.test-agent": [call("backup-1")], + "test.test-agent2": [call("backup-1"), call("backup-2")], + }, + ), + ( + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": None, + }, + "test.test-agent2": { + "protected": True, + "retention": { + "copies": 1, + "days": None, + }, + }, + }, + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": None, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-5": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-5", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + { + "test.test-agent2": [call("backup-1"), call("backup-2")], + }, + ), + ( + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": { + "copies": None, + "days": None, + }, + }, + "test.test-agent2": { + "protected": True, + "retention": None, + }, + }, + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 2, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-5": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-5", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + { + "test.test-agent2": [call("backup-1")], + }, + ), ], ) @patch("homeassistant.components.backup.config.BACKUP_START_TIME_JITTER", 0) @@ -3221,6 +3548,223 @@ async def test_config_retention_copies_logic_manual_backup( 1, {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), + ( + None, + [ + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": {"days": 3}, + }, + "test.test-agent2": { + "protected": True, + "retention": None, + }, + }, + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": {"recurrence": "never"}, + } + ], + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + { + "test.test-agent": [call("backup-1")], + "test.test-agent2": [call("backup-1"), call("backup-2")], + }, + ), + ( + None, + [ + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": {"days": 3}, + }, + "test.test-agent2": { + "protected": True, + "retention": None, + }, + }, + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": None}, + "schedule": {"recurrence": "never"}, + } + ], + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + { + "test.test-agent": [call("backup-1")], + }, + ), + ( + None, + [ + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": None, + }, + "test.test-agent2": { + "protected": True, + "retention": {"copies": None, "days": None}, + }, + }, + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": {"recurrence": "never"}, + } + ], + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + { + "test.test-agent": [call("backup-1"), call("backup-2")], + }, + ), ], ) async def test_config_retention_days_logic( @@ -3278,7 +3822,7 @@ async def test_config_retention_days_logic( freezer.move_to(start_time) mock_agents = await setup_backup_integration( - hass, remote_agents=["test.test-agent"] + hass, remote_agents=["test.test-agent", "test.test-agent2"] ) await hass.async_block_till_done() diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 70b826f0b92..a389f9fa818 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -1323,6 +1323,7 @@ async def test_async_play_media_url_m3u( "media_content_id": "media-source://media_source/local/test.mp3", "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, }, @@ -1337,6 +1338,7 @@ async def test_async_play_media_url_m3u( "media_content_id": ("media-source://media_source/local/test.mp4"), "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, }, diff --git a/tests/components/bluemaestro/__init__.py b/tests/components/bluemaestro/__init__.py index 412bc3cb7b3..e598eb34597 100644 --- a/tests/components/bluemaestro/__init__.py +++ b/tests/components/bluemaestro/__init__.py @@ -1,8 +1,48 @@ """Tests for the BlueMaestro integration.""" -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from uuid import UUID -NOT_BLUEMAESTRO_SERVICE_INFO = BluetoothServiceInfo( +from bleak.backends.device import BLEDevice +from bluetooth_data_tools import monotonic_time_coarse + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak + + +def make_bluetooth_service_info( + name: str, + manufacturer_data: dict[int, bytes], + service_uuids: list[str], + address: str, + rssi: int, + service_data: dict[UUID, bytes], + source: str, + tx_power: int = 0, + raw: bytes | None = None, +) -> BluetoothServiceInfoBleak: + """Create a BluetoothServiceInfoBleak object for testing.""" + return BluetoothServiceInfoBleak( + name=name, + manufacturer_data=manufacturer_data, + service_uuids=service_uuids, + address=address, + rssi=rssi, + service_data=service_data, + source=source, + device=BLEDevice( + name=name, + address=address, + details={}, + rssi=rssi, + ), + time=monotonic_time_coarse(), + advertisement=None, + connectable=True, + tx_power=tx_power, + raw=raw, + ) + + +NOT_BLUEMAESTRO_SERVICE_INFO = make_bluetooth_service_info( name="Not it", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -12,7 +52,7 @@ NOT_BLUEMAESTRO_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -BLUEMAESTRO_SERVICE_INFO = BluetoothServiceInfo( +BLUEMAESTRO_SERVICE_INFO = make_bluetooth_service_info( name="FA17B62C", manufacturer_data={ 307: b"\x17d\x0e\x10\x00\x02\x00\xf2\x01\xf2\x00\x83\x01\x00\x01\r\x02\xab\x00\xf2\x01\xf2\x01\r\x02\xab\x00\xf2\x01\xf2\x00\xff\x02N\x00\x00\x00\x00\x00" diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index e07b580acb2..e0b491e8f66 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -23,8 +23,7 @@ from . import ( @pytest.fixture(name="disable_bluez_manager_socket", autouse=True, scope="package") def disable_bluez_manager_socket(): """Mock the bluez manager socket.""" - with patch.object(bleak_manager, "get_global_bluez_manager_with_timeout"): - yield + bleak_manager.get_global_bluez_manager_with_timeout._has_dbus_socket = False @pytest.fixture(name="disable_dbus_socket", autouse=True, scope="package") diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 45d177de132..4561bcfb802 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -56,6 +56,7 @@ async def test_options_flow_disabled_not_setup( response = await ws_client.receive_json() assert response["result"][0]["supports_options"] is False await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures("macos_adapter") @@ -396,6 +397,7 @@ async def test_options_flow_linux(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_PASSIVE] is False await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures( @@ -425,6 +427,7 @@ async def test_options_flow_disabled_macos( response = await ws_client.receive_json() assert response["result"][0]["supports_options"] is False await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures( @@ -457,6 +460,7 @@ async def test_options_flow_enabled_linux( response = await ws_client.receive_json() assert response["result"][0]["supports_options"] is True await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures( @@ -487,6 +491,8 @@ async def test_options_flow_remote_adapter(hass: HomeAssistant) -> None: result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "remote_adapters_not_supported" + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures( @@ -514,6 +520,8 @@ async def test_options_flow_local_no_passive_support(hass: HomeAssistant) -> Non result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "local_adapters_no_passive_support" + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures("one_adapter") diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index e38ae19ce52..80fca88b2de 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -353,6 +353,7 @@ async def test_diagnostics_macos( "1": {"__type": "", "repr": "b'\\x01'"} }, "name": "wohand", + "raw": None, "rssi": -127, "service_data": {}, "service_uuids": [], @@ -382,6 +383,7 @@ async def test_diagnostics_macos( "1": {"__type": "", "repr": "b'\\x01'"} }, "name": "wohand", + "raw": None, "rssi": -127, "service_data": {}, "service_uuids": [], @@ -556,6 +558,7 @@ async def test_diagnostics_remote_adapter( "1": {"__type": "", "repr": "b'\\x01'"} }, "name": "wohand", + "raw": None, "rssi": -127, "service_data": {}, "service_uuids": [], @@ -585,6 +588,7 @@ async def test_diagnostics_remote_adapter( "1": {"__type": "", "repr": "b'\\x01'"} }, "name": "wohand", + "raw": None, "rssi": -127, "service_data": {}, "service_uuids": [], diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 48d1a38375d..bf773b69a99 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -61,6 +61,7 @@ from . import ( from tests.common import ( MockConfigEntry, MockModule, + async_call_logger_set_level, async_fire_time_changed, load_fixture, mock_integration, @@ -1144,54 +1145,45 @@ async def test_debug_logging( ) -> None: """Test debug logging.""" assert await async_setup_component(hass, "logger", {"logger": {}}) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.bluetooth": "DEBUG"}, - blocking=True, - ) - await hass.async_block_till_done() + async with async_call_logger_set_level( + "homeassistant.components.bluetooth", "DEBUG", hass=hass, caplog=caplog + ): + address = "44:44:33:11:23:41" + start_time_monotonic = 50.0 - address = "44:44:33:11:23:41" - start_time_monotonic = 50.0 + switchbot_device_poor_signal_hci0 = generate_ble_device( + address, "wohand_poor_signal_hci0" + ) + switchbot_adv_poor_signal_hci0 = generate_advertisement_data( + local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci0, + switchbot_adv_poor_signal_hci0, + start_time_monotonic, + "hci0", + ) + assert "wohand_poor_signal_hci0" in caplog.text + caplog.clear() - switchbot_device_poor_signal_hci0 = generate_ble_device( - address, "wohand_poor_signal_hci0" - ) - switchbot_adv_poor_signal_hci0 = generate_advertisement_data( - local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 - ) - inject_advertisement_with_time_and_source( - hass, - switchbot_device_poor_signal_hci0, - switchbot_adv_poor_signal_hci0, - start_time_monotonic, - "hci0", - ) - assert "wohand_poor_signal_hci0" in caplog.text - caplog.clear() - - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.bluetooth": "WARNING"}, - blocking=True, - ) - - switchbot_device_good_signal_hci0 = generate_ble_device( - address, "wohand_good_signal_hci0" - ) - switchbot_adv_good_signal_hci0 = generate_advertisement_data( - local_name="wohand_good_signal_hci0", service_uuids=[], rssi=-33 - ) - inject_advertisement_with_time_and_source( - hass, - switchbot_device_good_signal_hci0, - switchbot_adv_good_signal_hci0, - start_time_monotonic, - "hci0", - ) - assert "wohand_good_signal_hci0" not in caplog.text + async with async_call_logger_set_level( + "homeassistant.components.bluetooth", "WARNING", hass=hass, caplog=caplog + ): + switchbot_device_good_signal_hci0 = generate_ble_device( + address, "wohand_good_signal_hci0" + ) + switchbot_adv_good_signal_hci0 = generate_advertisement_data( + local_name="wohand_good_signal_hci0", service_uuids=[], rssi=-33 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_good_signal_hci0, + switchbot_adv_good_signal_hci0, + start_time_monotonic, + "hci0", + ) + assert "wohand_good_signal_hci0" not in caplog.text @pytest.mark.usefixtures("enable_bluetooth", "macos_adapter") diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index e9274965e3c..5d4dfcf103f 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -273,6 +273,111 @@ async def test_basic_usage(hass: HomeAssistant) -> None: cancel_coordinator() +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_async_set_updated_data_usage(hass: HomeAssistant) -> None: + """Test async_set_updated_data of the PassiveBluetoothProcessorCoordinator.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + @callback + def _mock_update_method( + service_info: BluetoothServiceInfo, + ) -> dict[str, str]: + return {"test": "data"} + + @callback + def _async_generate_mock_data( + data: dict[str, str], + ) -> PassiveBluetoothDataUpdate: + """Generate mock data.""" + assert data == {"test": "data"} + return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE + + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, + ) + assert coordinator.available is False # no data yet + + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + + unregister_processor = coordinator.async_register_processor(processor) + cancel_coordinator = coordinator.async_start() + + entity_key = PassiveBluetoothEntityKey("temperature", None) + entity_key_events = [] + all_events = [] + mock_entity = MagicMock() + mock_add_entities = MagicMock() + + def _async_entity_key_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock entity key listener.""" + entity_key_events.append(data) + + cancel_async_add_entity_key_listener = processor.async_add_entity_key_listener( + _async_entity_key_listener, + entity_key, + ) + + def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock an all listener.""" + all_events.append(data) + + cancel_listener = processor.async_add_listener( + _all_listener, + ) + + cancel_async_add_entities_listener = processor.async_add_entities_listener( + mock_entity, + mock_add_entities, + ) + + assert coordinator.available is False + coordinator.async_set_updated_data({"test": "data"}) + assert coordinator.available is True + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + + # Each listener should receive the same data + # since both match, and an additional all_events + # for the async_set_updated_data call + assert len(entity_key_events) == 1 + assert len(all_events) == 2 + + # There should be 4 calls to create entities + assert len(mock_entity.mock_calls) == 2 + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) + + # Only the all listener should receive the new data + # since temperature is not in the new data, and an additional all_events + # for the async_set_updated_data call + assert len(entity_key_events) == 1 + assert len(all_events) == 3 + + # On the second, the entities should already be created + # so the mock should not be called again + assert len(mock_entity.mock_calls) == 2 + + cancel_async_add_entity_key_listener() + cancel_listener() + cancel_async_add_entities_listener() + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + + # Each listener should not trigger any more now + # that they were cancelled + assert len(entity_key_events) == 1 + assert len(all_events) == 3 + assert len(mock_entity.mock_calls) == 2 + assert coordinator.available is True + + unregister_processor() + cancel_coordinator() + + @pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") async def test_entity_key_is_dispatched_on_entity_key_change( hass: HomeAssistant, diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index 6acb86476e7..142438fbb95 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -29,7 +29,11 @@ from . import ( patch_bluetooth_time, ) -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_call_logger_set_level, + async_fire_time_changed, +) # If the adapter is in a stuck state the following errors are raised: NEED_RESET_ERRORS = [ @@ -482,70 +486,67 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( ) -> None: """Test we can recover the adapter at startup and we wait for Dbus to init.""" assert await async_setup_component(hass, "logger", {}) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.bluetooth": "DEBUG"}, - blocking=True, - ) - called_start = 0 - called_stop = 0 - _callback = None - mock_discovered = [] - - class MockBleakScanner: - async def start(self, *args, **kwargs): - """Mock Start.""" - nonlocal called_start - called_start += 1 - if called_start == 1: - raise BleakError("org.freedesktop.DBus.Error.UnknownObject") - if called_start == 2: - raise BleakError("org.bluez.Error.InProgress") - if called_start == 3: - raise BleakError("org.bluez.Error.InProgress") - - async def stop(self, *args, **kwargs): - """Mock Start.""" - nonlocal called_stop - called_stop += 1 - - @property - def discovered_devices(self): - """Mock discovered_devices.""" - nonlocal mock_discovered - return mock_discovered - - def register_detection_callback(self, callback: AdvertisementDataCallback): - """Mock Register Detection Callback.""" - nonlocal _callback - _callback = callback - - scanner = MockBleakScanner() - start_time_monotonic = time.monotonic() - - with ( - patch( - "habluetooth.scanner.ADAPTER_INIT_TIME", - 0, - ), - patch_bluetooth_time( - start_time_monotonic, - ), - patch( - "habluetooth.scanner.OriginalBleakScanner", - return_value=scanner, - ), - patch( - "habluetooth.util.recover_adapter", return_value=True - ) as mock_recover_adapter, + async with async_call_logger_set_level( + "homeassistant.components.bluetooth", "DEBUG", hass=hass, caplog=caplog ): - await async_setup_with_one_adapter(hass) + called_start = 0 + called_stop = 0 + _callback = None + mock_discovered = [] - assert called_start == 4 + class MockBleakScanner: + async def start(self, *args, **kwargs): + """Mock Start.""" + nonlocal called_start + called_start += 1 + if called_start == 1: + raise BleakError("org.freedesktop.DBus.Error.UnknownObject") + if called_start == 2: + raise BleakError("org.bluez.Error.InProgress") + if called_start == 3: + raise BleakError("org.bluez.Error.InProgress") - assert len(mock_recover_adapter.mock_calls) == 1 - assert "Waiting for adapter to initialize" in caplog.text + async def stop(self, *args, **kwargs): + """Mock Start.""" + nonlocal called_stop + called_stop += 1 + + @property + def discovered_devices(self): + """Mock discovered_devices.""" + nonlocal mock_discovered + return mock_discovered + + def register_detection_callback(self, callback: AdvertisementDataCallback): + """Mock Register Detection Callback.""" + nonlocal _callback + _callback = callback + + scanner = MockBleakScanner() + start_time_monotonic = time.monotonic() + + with ( + patch( + "habluetooth.scanner.ADAPTER_INIT_TIME", + 0, + ), + patch_bluetooth_time( + start_time_monotonic, + ), + patch( + "habluetooth.scanner.OriginalBleakScanner", + return_value=scanner, + ), + patch( + "habluetooth.util.recover_adapter", return_value=True + ) as mock_recover_adapter, + ): + await async_setup_with_one_adapter(hass) + + assert called_start == 4 + + assert len(mock_recover_adapter.mock_calls) == 1 + assert "Waiting for adapter to initialize" in caplog.text @pytest.mark.usefixtures("one_adapter") diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index c5908776882..bfe7445f614 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -316,65 +316,6 @@ async def test_release_slot_on_connect_exception( cancel_hci1() -@pytest.mark.usefixtures("enable_bluetooth", "two_adapters") -async def test_we_switch_adapters_on_failure( - hass: HomeAssistant, - install_bleak_catcher, -) -> None: - """Ensure we try the next best adapter after a failure.""" - hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices( - hass - ) - ble_device = hci0_device_advs["00:00:00:00:00:01"][0] - client = bleak.BleakClient(ble_device) - - class FakeBleakClientFailsHCI0Only(BaseFakeBleakClient): - """Fake bleak client that fails to connect.""" - - async def connect(self, *args, **kwargs): - """Connect.""" - if "/hci0/" in self._device.details["path"]: - return False - return True - - with patch( - "habluetooth.wrappers.get_platform_client_backend_type", - return_value=FakeBleakClientFailsHCI0Only, - ): - assert await client.connect() is False - - with patch( - "habluetooth.wrappers.get_platform_client_backend_type", - return_value=FakeBleakClientFailsHCI0Only, - ): - assert await client.connect() is False - - # After two tries we should switch to hci1 - with patch( - "habluetooth.wrappers.get_platform_client_backend_type", - return_value=FakeBleakClientFailsHCI0Only, - ): - assert await client.connect() is True - - # ..and we remember that hci1 works as long as the client doesn't change - with patch( - "habluetooth.wrappers.get_platform_client_backend_type", - return_value=FakeBleakClientFailsHCI0Only, - ): - assert await client.connect() is True - - # If we replace the client, we should try hci0 again - client = bleak.BleakClient(ble_device) - - with patch( - "habluetooth.wrappers.get_platform_client_backend_type", - return_value=FakeBleakClientFailsHCI0Only, - ): - assert await client.connect() is False - cancel_hci0() - cancel_hci1() - - @pytest.mark.usefixtures("enable_bluetooth", "two_adapters") async def test_passing_subclassed_str_as_address( hass: HomeAssistant, diff --git a/tests/components/bmw_connected_drive/snapshots/test_select.ambr b/tests/components/bmw_connected_drive/snapshots/test_select.ambr index de76b07057e..0edead03f26 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_select.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_select.ambr @@ -30,7 +30,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Charging Mode', + 'original_name': 'Charging mode', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -42,7 +42,7 @@ # name: test_entity_state_attrs[select.i3_rex_charging_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'i3 (+ REX) Charging Mode', + 'friendly_name': 'i3 (+ REX) Charging mode', 'options': list([ 'immediate_charging', 'delayed_charging', @@ -98,7 +98,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Charging Limit', + 'original_name': 'AC charging limit', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -110,7 +110,7 @@ # name: test_entity_state_attrs[select.i4_edrive40_ac_charging_limit-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'i4 eDrive40 AC Charging Limit', + 'friendly_name': 'i4 eDrive40 AC charging limit', 'options': list([ '6', '7', @@ -167,7 +167,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Charging Mode', + 'original_name': 'Charging mode', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -179,7 +179,7 @@ # name: test_entity_state_attrs[select.i4_edrive40_charging_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'i4 eDrive40 Charging Mode', + 'friendly_name': 'i4 eDrive40 Charging mode', 'options': list([ 'immediate_charging', 'delayed_charging', @@ -235,7 +235,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Charging Limit', + 'original_name': 'AC charging limit', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -247,7 +247,7 @@ # name: test_entity_state_attrs[select.ix_xdrive50_ac_charging_limit-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'iX xDrive50 AC Charging Limit', + 'friendly_name': 'iX xDrive50 AC charging limit', 'options': list([ '6', '7', @@ -304,7 +304,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Charging Mode', + 'original_name': 'Charging mode', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -316,7 +316,7 @@ # name: test_entity_state_attrs[select.ix_xdrive50_charging_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'iX xDrive50 Charging Mode', + 'friendly_name': 'iX xDrive50 Charging mode', 'options': list([ 'immediate_charging', 'delayed_charging', diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index 73aece4af6b..e5139b253aa 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -15,6 +15,8 @@ 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.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .common import ( @@ -63,6 +65,59 @@ async def test_user_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_form_can_create_when_already_discovered( + hass: HomeAssistant, +) -> None: + """Test we get the user initiated form can create when already discovered.""" + + with patch_bond_version(), patch_bond_token(): + zc_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + hostname="mock_hostname", + name="ZXXX12345.some-other-tail-info", + port=None, + properties={}, + type="mock_type", + ), + ) + assert zc_result["type"] is FlowResultType.FORM + assert zc_result["errors"] == {} + + 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_bond_version(return_value={"bondid": "ZXXX12345"}), + patch_bond_device_ids(return_value=["f6776c11", "f6776c12"]), + patch_bond_bridge(), + patch_bond_device_properties(), + patch_bond_device(), + patch_bond_device_state(), + _patch_async_setup_entry() as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "bond-name" + assert result2["data"] == { + CONF_HOST: "some host", + CONF_ACCESS_TOKEN: "test-token", + } + assert result2["result"].unique_id == "ZXXX12345" + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_user_form_with_non_bridge(hass: HomeAssistant) -> None: """Test setup a smart by bond fan.""" @@ -97,6 +152,7 @@ async def test_user_form_with_non_bridge(hass: HomeAssistant) -> None: CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token", } + assert result2["result"].unique_id == "KXXX12345" assert len(mock_setup_entry.mock_calls) == 1 @@ -253,6 +309,107 @@ async def test_zeroconf_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_dhcp_discovery(hass: HomeAssistant) -> None: + """Test DHCP discovery.""" + + with patch_bond_version(), patch_bond_token(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="127.0.0.1", + hostname="Bond-KVPRBDJ45842", + macaddress=format_mac("3c:6a:2c:1c:8c:80"), + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch_bond_version(return_value={"bondid": "KVPRBDJ45842"}), + patch_bond_bridge(), + patch_bond_device_ids(), + _patch_async_setup_entry() as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "test-token"}, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "bond-name" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_ACCESS_TOKEN: "test-token", + } + assert result2["result"].unique_id == "KVPRBDJ45842" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_discovery_already_exists(hass: HomeAssistant) -> None: + """Test DHCP discovery for an already existing entry.""" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="KVPRBDJ45842", + ) + entry.add_to_hass(hass) + + with ( + patch_bond_version(return_value={"bondid": "KVPRBDJ45842"}), + patch_bond_token(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="127.0.0.1", + hostname="Bond-KVPRBDJ45842".lower(), + macaddress=format_mac("3c:6a:2c:1c:8c:80"), + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery_short_name(hass: HomeAssistant) -> None: + """Test DHCP discovery with the name cut off.""" + + with patch_bond_version(), patch_bond_token(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="127.0.0.1", + hostname="Bond-KVPRBDJ", + macaddress=format_mac("3c:6a:2c:1c:8c:80"), + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch_bond_version(return_value={"bondid": "KVPRBDJ45842"}), + patch_bond_bridge(), + patch_bond_device_ids(), + _patch_async_setup_entry() as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "test-token"}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "bond-name" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_ACCESS_TOKEN: "test-token", + } + assert result2["result"].unique_id == "KVPRBDJ45842" + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_zeroconf_form_token_unavailable(hass: HomeAssistant) -> None: """Test we get the discovery form and we handle the token being unavailable.""" diff --git a/tests/components/bosch_alarm/conftest.py b/tests/components/bosch_alarm/conftest.py index 45ec0072a37..02ec592d061 100644 --- a/tests/components/bosch_alarm/conftest.py +++ b/tests/components/bosch_alarm/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock, patch -from bosch_alarm_mode2.panel import Area +from bosch_alarm_mode2.panel import Area, Door, Output, Point from bosch_alarm_mode2.utils import Observable import pytest @@ -78,14 +78,66 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture +def points() -> Generator[dict[int, Point]]: + """Define a mocked door.""" + names = [ + "Window", + "Door", + "Motion Detector", + "CO Detector", + "Smoke Detector", + "Glassbreak Sensor", + "Bedroom", + ] + points = {} + for i, name in enumerate(names): + mock = AsyncMock(spec=Point) + mock.name = name + mock.status_observer = AsyncMock(spec=Observable) + mock.is_open.return_value = False + mock.is_normal.return_value = True + points[i] = mock + return points + + +@pytest.fixture +def output() -> Generator[Output]: + """Define a mocked output.""" + mock = AsyncMock(spec=Output) + mock.name = "Output A" + mock.status_observer = AsyncMock(spec=Observable) + mock.is_active.return_value = False + return mock + + +@pytest.fixture +def door() -> Generator[Door]: + """Define a mocked door.""" + mock = AsyncMock(spec=Door) + mock.name = "Main Door" + mock.status_observer = AsyncMock(spec=Observable) + mock.is_open.return_value = False + mock.is_locked.return_value = True + return mock + + @pytest.fixture def area() -> Generator[Area]: """Define a mocked area.""" mock = AsyncMock(spec=Area) mock.name = "Area1" mock.status_observer = AsyncMock(spec=Observable) + mock.alarm_observer = AsyncMock(spec=Observable) + mock.ready_observer = AsyncMock(spec=Observable) + mock.alarms = [] + mock.alarms_ids = [] + mock.faults = 0 + mock.all_ready = True + mock.part_ready = True mock.is_triggered.return_value = False mock.is_disarmed.return_value = True + mock.is_armed.return_value = False mock.is_arming.return_value = False mock.is_pending.return_value = False mock.is_part_armed.return_value = False @@ -95,7 +147,12 @@ def area() -> Generator[Area]: @pytest.fixture def mock_panel( - area: AsyncMock, model_name: str, serial_number: str | None + area: AsyncMock, + door: AsyncMock, + output: AsyncMock, + points: dict[int, AsyncMock], + model_name: str, + serial_number: str | None, ) -> Generator[AsyncMock]: """Define a fixture to set up Bosch Alarm.""" with ( @@ -106,10 +163,18 @@ def mock_panel( ): client = mock_panel.return_value client.areas = {1: area} + client.doors = {1: door} + client.outputs = {1: output} + client.points = points client.model = model_name + client.faults = [] + client.events = [] client.firmware_version = "1.0.0" + client.protocol_version = "1.0.0" client.serial_number = serial_number client.connection_status_observer = AsyncMock(spec=Observable) + client.faults_observer = AsyncMock(spec=Observable) + client.history_observer = AsyncMock(spec=Observable) yield client diff --git a/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..459ddf7a213 --- /dev/null +++ b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr @@ -0,0 +1,287 @@ +# serializer version: 1 +# name: test_diagnostics[amax_3000] + dict({ + 'data': dict({ + 'areas': list([ + dict({ + 'alarms': list([ + ]), + 'all_armed': False, + 'all_ready': True, + 'armed': False, + 'arming': False, + 'disarmed': True, + 'faults': 0, + 'id': 1, + 'name': 'Area1', + 'part_armed': False, + 'part_ready': True, + 'pending': False, + 'triggered': False, + }), + ]), + 'doors': list([ + dict({ + 'id': 1, + 'locked': True, + 'name': 'Main Door', + 'open': False, + }), + ]), + 'firmware_version': '1.0.0', + 'history_events': list([ + ]), + 'model': 'AMAX 3000', + 'outputs': list([ + dict({ + 'active': False, + 'id': 1, + 'name': 'Output A', + }), + ]), + 'points': list([ + dict({ + 'id': 0, + 'name': 'Window', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 1, + 'name': 'Door', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 2, + 'name': 'Motion Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 3, + 'name': 'CO Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 4, + 'name': 'Smoke Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 5, + 'name': 'Glassbreak Sensor', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 6, + 'name': 'Bedroom', + 'normal': True, + 'open': False, + }), + ]), + 'protocol_version': '1.0.0', + 'serial_number': None, + }), + 'entry_data': dict({ + 'host': '0.0.0.0', + 'installer_code': '**REDACTED**', + 'model': 'AMAX 3000', + 'password': '**REDACTED**', + 'port': 7700, + }), + }) +# --- +# name: test_diagnostics[b5512] + dict({ + 'data': dict({ + 'areas': list([ + dict({ + 'alarms': list([ + ]), + 'all_armed': False, + 'all_ready': True, + 'armed': False, + 'arming': False, + 'disarmed': True, + 'faults': 0, + 'id': 1, + 'name': 'Area1', + 'part_armed': False, + 'part_ready': True, + 'pending': False, + 'triggered': False, + }), + ]), + 'doors': list([ + dict({ + 'id': 1, + 'locked': True, + 'name': 'Main Door', + 'open': False, + }), + ]), + 'firmware_version': '1.0.0', + 'history_events': list([ + ]), + 'model': 'B5512 (US1B)', + 'outputs': list([ + dict({ + 'active': False, + 'id': 1, + 'name': 'Output A', + }), + ]), + 'points': list([ + dict({ + 'id': 0, + 'name': 'Window', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 1, + 'name': 'Door', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 2, + 'name': 'Motion Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 3, + 'name': 'CO Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 4, + 'name': 'Smoke Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 5, + 'name': 'Glassbreak Sensor', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 6, + 'name': 'Bedroom', + 'normal': True, + 'open': False, + }), + ]), + 'protocol_version': '1.0.0', + 'serial_number': None, + }), + 'entry_data': dict({ + 'host': '0.0.0.0', + 'model': 'B5512 (US1B)', + 'password': '**REDACTED**', + 'port': 7700, + }), + }) +# --- +# name: test_diagnostics[solution_3000] + dict({ + 'data': dict({ + 'areas': list([ + dict({ + 'alarms': list([ + ]), + 'all_armed': False, + 'all_ready': True, + 'armed': False, + 'arming': False, + 'disarmed': True, + 'faults': 0, + 'id': 1, + 'name': 'Area1', + 'part_armed': False, + 'part_ready': True, + 'pending': False, + 'triggered': False, + }), + ]), + 'doors': list([ + dict({ + 'id': 1, + 'locked': True, + 'name': 'Main Door', + 'open': False, + }), + ]), + 'firmware_version': '1.0.0', + 'history_events': list([ + ]), + 'model': 'Solution 3000', + 'outputs': list([ + dict({ + 'active': False, + 'id': 1, + 'name': 'Output A', + }), + ]), + 'points': list([ + dict({ + 'id': 0, + 'name': 'Window', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 1, + 'name': 'Door', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 2, + 'name': 'Motion Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 3, + 'name': 'CO Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 4, + 'name': 'Smoke Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 5, + 'name': 'Glassbreak Sensor', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 6, + 'name': 'Bedroom', + 'normal': True, + 'open': False, + }), + ]), + 'protocol_version': '1.0.0', + 'serial_number': '1234567890', + }), + 'entry_data': dict({ + 'host': '0.0.0.0', + 'model': 'Solution 3000', + 'port': 7700, + 'user_code': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/bosch_alarm/snapshots/test_sensor.ambr b/tests/components/bosch_alarm/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..def2c503a6a --- /dev/null +++ b/tests/components/bosch_alarm/snapshots/test_sensor.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_sensor[amax_3000][sensor.area1_faulting_points-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_faulting_points', + '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': 'Faulting points', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'faulting_points', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_faulting_points', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_sensor[amax_3000][sensor.area1_faulting_points-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Faulting points', + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.area1_faulting_points', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[b5512][sensor.area1_faulting_points-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_faulting_points', + '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': 'Faulting points', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'faulting_points', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_faulting_points', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_sensor[b5512][sensor.area1_faulting_points-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Faulting points', + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.area1_faulting_points', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[solution_3000][sensor.area1_faulting_points-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_faulting_points', + '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': 'Faulting points', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'faulting_points', + 'unique_id': '1234567890_area_1_faulting_points', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_sensor[solution_3000][sensor.area1_faulting_points-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Faulting points', + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.area1_faulting_points', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/bosch_alarm/test_config_flow.py b/tests/components/bosch_alarm/test_config_flow.py index 066b3008821..9e79d1c1f5f 100644 --- a/tests/components/bosch_alarm/test_config_flow.py +++ b/tests/components/bosch_alarm/test_config_flow.py @@ -13,6 +13,8 @@ from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import setup_integration + from tests.common import MockConfigEntry @@ -210,3 +212,156 @@ async def test_entry_already_configured_serial( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test reauth flow.""" + await setup_integration(hass, mock_config_entry) + result = await mock_config_entry.start_reauth_flow(hass) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + + assert result["step_id"] == "reauth_confirm" + # Now check it works when there are no errors + mock_panel.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["reason"] == "reauth_successful" + compare = {**mock_config_entry.data, **config_flow_data} + assert compare == mock_config_entry.data + + +@pytest.mark.parametrize( + ("exception", "message"), + [ + (OSError(), "cannot_connect"), + (PermissionError(), "invalid_auth"), + (Exception(), "unknown"), + ], +) +async def test_reauth_flow_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], + exception: Exception, + message: str, +) -> None: + """Test reauth flow.""" + await setup_integration(hass, mock_config_entry) + result = await mock_config_entry.start_reauth_flow(hass) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + + assert result["step_id"] == "reauth_confirm" + mock_panel.connect.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["step_id"] == "reauth_confirm" + assert result["errors"]["base"] == message + + mock_panel.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["reason"] == "reauth_successful" + compare = {**mock_config_entry.data, **config_flow_data} + assert compare == mock_config_entry.data + + +async def test_reconfig_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test reconfig auth.""" + await setup_integration(hass, mock_config_entry) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 7700}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config_flow_data, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 7700, + CONF_MODEL: model_name, + **config_flow_data, + } + + +@pytest.mark.parametrize("model", ["b5512"]) +async def test_reconfig_flow_incorrect_model( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test reconfig fails with a different device.""" + await setup_integration(hass, mock_config_entry) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + ) + + mock_panel.model = "Solution 3000" + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "0.0.0.0", CONF_PORT: 7700}, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "device_mismatch" diff --git a/tests/components/bosch_alarm/test_diagnostics.py b/tests/components/bosch_alarm/test_diagnostics.py new file mode 100644 index 00000000000..3e10878bd07 --- /dev/null +++ b/tests/components/bosch_alarm/test_diagnostics.py @@ -0,0 +1,32 @@ +"""Test the Bosch Alarm diagnostics.""" + +from typing import Any +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_panel: AsyncMock, + area: AsyncMock, + model_name: str, + serial_number: str, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + config_flow_data: dict[str, Any], +) -> None: + """Test generating diagnostics for bosch alarm.""" + await setup_integration(hass, mock_config_entry) + + diag = await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + assert diag == snapshot diff --git a/tests/components/bosch_alarm/test_init.py b/tests/components/bosch_alarm/test_init.py index 0497a91eadf..13e938bd711 100644 --- a/tests/components/bosch_alarm/test_init.py +++ b/tests/components/bosch_alarm/test_init.py @@ -20,12 +20,26 @@ def disable_platform_only(): @pytest.mark.parametrize("model", ["solution_3000"]) -@pytest.mark.parametrize("exception", [PermissionError(), TimeoutError()]) +@pytest.mark.parametrize("exception", [PermissionError()]) async def test_incorrect_auth( hass: HomeAssistant, mock_panel: AsyncMock, mock_config_entry: MockConfigEntry, exception: Exception, +) -> None: + """Test errors with incorrect auth.""" + mock_panel.connect.side_effect = exception + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.parametrize("model", ["solution_3000"]) +@pytest.mark.parametrize("exception", [TimeoutError()]) +async def test_connection_error( + hass: HomeAssistant, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, ) -> None: """Test errors with incorrect auth.""" mock_panel.connect.side_effect = exception diff --git a/tests/components/bosch_alarm/test_sensor.py b/tests/components/bosch_alarm/test_sensor.py new file mode 100644 index 00000000000..02153a9656e --- /dev/null +++ b/tests/components/bosch_alarm/test_sensor.py @@ -0,0 +1,52 @@ +"""Tests for Bosch Alarm component.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import call_observable, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.bosch_alarm.PLATFORMS", [Platform.SENSOR]): + yield + + +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the sensor state.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_faulting_points( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that area faulting point count changes after arming the panel.""" + await setup_integration(hass, mock_config_entry) + entity_id = "sensor.area1_faulting_points" + assert hass.states.get(entity_id).state == "0" + + area.faults = 1 + await call_observable(hass, area.ready_observer) + + assert hass.states.get(entity_id).state == "1" diff --git a/tests/components/cambridge_audio/snapshots/test_media_browser.ambr b/tests/components/cambridge_audio/snapshots/test_media_browser.ambr index 180d5ed1bb0..9f0fffdac49 100644 --- a/tests/components/cambridge_audio/snapshots/test_media_browser.ambr +++ b/tests/components/cambridge_audio/snapshots/test_media_browser.ambr @@ -4,6 +4,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'directory', 'media_content_id': '', @@ -18,6 +19,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'music', 'media_content_id': '1', @@ -28,6 +30,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'music', 'media_content_id': '2', diff --git a/tests/components/cambridge_audio/test_media_player.py b/tests/components/cambridge_audio/test_media_player.py index bb2ccd1aec4..ef7e911fbba 100644 --- a/tests/components/cambridge_audio/test_media_player.py +++ b/tests/components/cambridge_audio/test_media_player.py @@ -10,6 +10,7 @@ from aiostreammagic import ( import pytest from homeassistant.components.media_player import ( + ATTR_MEDIA_ARTIST, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_REPEAT, @@ -489,3 +490,41 @@ async def test_play_media_unknown_type( }, blocking=True, ) + + +@pytest.mark.parametrize( + ("source_id", "artist", "station", "display"), + [ + ("MEDIA_PLAYER", "Metallica", None, "Metallica"), + ("USB_AUDIO", "Iron Maiden", "Radio BOB!", "Iron Maiden"), + ("IR", "In Flames", "Radio BOB!", "In Flames"), + ("IR", None, "Radio BOB!", "Radio BOB!"), + ("IR", None, None, None), + ("MEDIA_PLAYER", None, "Radio BOB!", None), + ], +) +async def test_media_artist( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_config_entry: MockConfigEntry, + source_id: str, + artist: str, + station: str, + display: str, +) -> None: + """Test media player state.""" + await setup_integration(hass, mock_config_entry) + mock_stream_magic_client.play_state.metadata.artist = artist + mock_stream_magic_client.play_state.metadata.station = station + mock_stream_magic_client.state.source = source_id + + await mock_state_update(mock_stream_magic_client) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + if (artist is None and source_id != "IR") or ( + source_id == "IR" and station is None + ): + assert ATTR_MEDIA_ARTIST not in state.attributes + else: + assert state.attributes[ATTR_MEDIA_ARTIST] == display diff --git a/tests/components/canary/__init__.py b/tests/components/canary/__init__.py index 13c4b84ab94..b247bfc35d6 100644 --- a/tests/components/canary/__init__.py +++ b/tests/components/canary/__init__.py @@ -37,13 +37,6 @@ YAML_CONFIG = { } -def _patch_async_setup(return_value=True): - return patch( - "homeassistant.components.canary.async_setup", - return_value=return_value, - ) - - def _patch_async_setup_entry(return_value=True): return patch( "homeassistant.components.canary.async_setup_entry", diff --git a/tests/components/canary/test_alarm_control_panel.py b/tests/components/canary/test_alarm_control_panel.py index a194621b0d9..2df75ad5c59 100644 --- a/tests/components/canary/test_alarm_control_panel.py +++ b/tests/components/canary/test_alarm_control_panel.py @@ -8,7 +8,6 @@ from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_DOMAIN, AlarmControlPanelState, ) -from homeassistant.components.canary import DOMAIN from homeassistant.const import ( SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, @@ -19,9 +18,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.setup import async_setup_component -from . import mock_device, mock_location, mock_mode +from . import init_integration, mock_device, mock_location, mock_mode async def test_alarm_control_panel( @@ -43,10 +41,8 @@ async def test_alarm_control_panel( instance = canary.return_value instance.get_locations.return_value = [mocked_location] - config = {DOMAIN: {"username": "test-username", "password": "test-password"}} with patch("homeassistant.components.canary.PLATFORMS", ["alarm_control_panel"]): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + await init_integration(hass) entity_id = "alarm_control_panel.home" entity_entry = entity_registry.async_get(entity_id) @@ -124,10 +120,8 @@ async def test_alarm_control_panel_services(hass: HomeAssistant, canary) -> None instance = canary.return_value instance.get_locations.return_value = [mocked_location] - config = {DOMAIN: {"username": "test-username", "password": "test-password"}} with patch("homeassistant.components.canary.PLATFORMS", ["alarm_control_panel"]): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + await init_integration(hass) entity_id = "alarm_control_panel.home" diff --git a/tests/components/canary/test_config_flow.py b/tests/components/canary/test_config_flow.py index 552aa9089ce..06aadc8297c 100644 --- a/tests/components/canary/test_config_flow.py +++ b/tests/components/canary/test_config_flow.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_TIMEOUT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import USER_INPUT, _patch_async_setup, _patch_async_setup_entry, init_integration +from . import USER_INPUT, _patch_async_setup_entry, init_integration async def test_user_form(hass: HomeAssistant, canary_config_flow) -> None: @@ -27,10 +27,7 @@ async def test_user_form(hass: HomeAssistant, canary_config_flow) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - _patch_async_setup() as mock_setup, - _patch_async_setup_entry() as mock_setup_entry, - ): + with _patch_async_setup_entry() as mock_setup_entry: result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, @@ -41,7 +38,6 @@ async def test_user_form(hass: HomeAssistant, canary_config_flow) -> None: assert result["title"] == "test-username" assert result["data"] == {**USER_INPUT, CONF_TIMEOUT: DEFAULT_TIMEOUT} - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -120,7 +116,7 @@ async def test_options_flow(hass: HomeAssistant, canary) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with _patch_async_setup(), _patch_async_setup_entry(): + with _patch_async_setup_entry(): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_FFMPEG_ARGUMENTS: "-v", CONF_TIMEOUT: 7}, diff --git a/tests/components/canary/test_init.py b/tests/components/canary/test_init.py index e0d1c532efc..67cb11207df 100644 --- a/tests/components/canary/test_init.py +++ b/tests/components/canary/test_init.py @@ -1,59 +1,12 @@ """The tests for the Canary component.""" -from unittest.mock import patch - from requests import ConnectTimeout -from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN -from homeassistant.components.canary.const import CONF_FFMPEG_ARGUMENTS, DOMAIN +from homeassistant.components.canary.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from . import YAML_CONFIG, init_integration - - -async def test_import_from_yaml(hass: HomeAssistant, canary) -> None: - """Test import from YAML.""" - with patch( - "homeassistant.components.canary.async_setup_entry", - return_value=True, - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: YAML_CONFIG}) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - - assert entries[0].data[CONF_USERNAME] == "test-username" - assert entries[0].data[CONF_PASSWORD] == "test-password" - assert entries[0].data[CONF_TIMEOUT] == 5 - - -async def test_import_from_yaml_ffmpeg(hass: HomeAssistant, canary) -> None: - """Test import from YAML with ffmpeg arguments.""" - with patch( - "homeassistant.components.canary.async_setup_entry", - return_value=True, - ): - assert await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: YAML_CONFIG, - CAMERA_DOMAIN: [{"platform": DOMAIN, CONF_FFMPEG_ARGUMENTS: "-v"}], - }, - ) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - - assert entries[0].data[CONF_USERNAME] == "test-username" - assert entries[0].data[CONF_PASSWORD] == "test-password" - assert entries[0].data[CONF_TIMEOUT] == 5 - assert entries[0].data.get(CONF_FFMPEG_ARGUMENTS) == "-v" +from . import init_integration async def test_unload_entry(hass: HomeAssistant, canary) -> None: diff --git a/tests/components/canary/test_sensor.py b/tests/components/canary/test_sensor.py index afcf9f16db4..b5a79724ddb 100644 --- a/tests/components/canary/test_sensor.py +++ b/tests/components/canary/test_sensor.py @@ -20,10 +20,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from . import mock_device, mock_location, mock_reading +from . import init_integration, mock_device, mock_location, mock_reading from tests.common import async_fire_time_changed @@ -48,10 +47,8 @@ async def test_sensors_pro( mock_reading("air_quality", "0.59"), ] - config = {DOMAIN: {"username": "test-username", "password": "test-password"}} with patch("homeassistant.components.canary.PLATFORMS", ["sensor"]): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + await init_integration(hass) sensors = { "home_dining_room_temperature": ( @@ -112,10 +109,8 @@ async def test_sensors_attributes_pro(hass: HomeAssistant, canary) -> None: mock_reading("air_quality", "0.59"), ] - config = {DOMAIN: {"username": "test-username", "password": "test-password"}} with patch("homeassistant.components.canary.PLATFORMS", ["sensor"]): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + await init_integration(hass) entity_id = "sensor.home_dining_room_air_quality" state1 = hass.states.get(entity_id) @@ -175,10 +170,8 @@ async def test_sensors_flex( mock_reading("wifi", "-57"), ] - config = {DOMAIN: {"username": "test-username", "password": "test-password"}} with patch("homeassistant.components.canary.PLATFORMS", ["sensor"]): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + await init_integration(hass) sensors = { "home_dining_room_battery": ( diff --git a/tests/components/cast/test_config_flow.py b/tests/components/cast/test_config_flow.py index e02230892bf..99f3113a10b 100644 --- a/tests/components/cast/test_config_flow.py +++ b/tests/components/cast/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.components.cast.home_assistant_cast import CAST_USER_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value async def test_creating_entry_sets_up_media_player(hass: HomeAssistant) -> None: @@ -141,16 +141,6 @@ async def test_zeroconf_setup_onboarding(hass: HomeAssistant) -> None: } -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - return None - - @pytest.mark.parametrize( ("parameter", "initial", "suggested", "user_input", "updated"), [ @@ -219,9 +209,9 @@ async def test_option_flow( for other_param in basic_parameters: if other_param == parameter: continue - assert get_suggested(data_schema, other_param) == [] + assert get_schema_suggested_value(data_schema, other_param) == [] if parameter in basic_parameters: - assert get_suggested(data_schema, parameter) == suggested + assert get_schema_suggested_value(data_schema, parameter) == suggested user_input_dict = {} if parameter in basic_parameters: @@ -244,9 +234,9 @@ async def test_option_flow( for other_param in advanced_parameters: if other_param == parameter: continue - assert get_suggested(data_schema, other_param) == "" + assert get_schema_suggested_value(data_schema, other_param) == "" if parameter in advanced_parameters: - assert get_suggested(data_schema, parameter) == suggested + assert get_schema_suggested_value(data_schema, parameter) == suggested user_input_dict = {} if parameter in advanced_parameters: diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 668ed985154..386b9270571 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -1037,6 +1037,7 @@ async def test_entity_browse_media( ), "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, } @@ -1049,6 +1050,7 @@ async def test_entity_browse_media( "media_content_id": "media-source://media_source/local/test.mp3", "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, } @@ -1107,6 +1109,7 @@ async def test_entity_browse_media_audio_only( "media_content_id": "media-source://media_source/local/test.mp3", "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, } @@ -2208,6 +2211,7 @@ async def test_cast_platform_browse_media( "media_content_id": "", "can_play": False, "can_expand": True, + "can_search": False, "thumbnail": "https://brands.home-assistant.io/_/spotify/logo.png", "children_media_class": None, } @@ -2232,6 +2236,7 @@ async def test_cast_platform_browse_media( "media_content_id": "", "can_play": True, "can_expand": False, + "can_search": False, "children_media_class": None, "thumbnail": None, "children": [], diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 2d594fd9345..0e118f251de 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -218,9 +218,9 @@ def mock_user_data() -> Generator[MagicMock]: @pytest.fixture -def mock_cloud_fixture(hass: HomeAssistant) -> CloudPreferences: +async def mock_cloud_fixture(hass: HomeAssistant) -> CloudPreferences: """Fixture for cloud component.""" - hass.loop.run_until_complete(mock_cloud(hass)) + await mock_cloud(hass) return mock_cloud_prefs(hass, {}) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 81e8554ebf2..2722445445e 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -4,7 +4,6 @@ from collections.abc import Callable, Coroutine from copy import deepcopy import datetime from http import HTTPStatus -import json import logging from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch @@ -20,7 +19,6 @@ from hass_nabucasa.auth import ( ) from hass_nabucasa.const import STATE_CONNECTED from hass_nabucasa.remote import CertificateStatus -from hass_nabucasa.voice import TTS_VOICES import pytest from syrupy.assertion import SnapshotAssertion @@ -31,6 +29,7 @@ from homeassistant.components.alexa import errors as alexa_errors from homeassistant.components.alexa.entities import LightCapabilities from homeassistant.components.assist_pipeline.pipeline import STORAGE_KEY from homeassistant.components.cloud.const import DEFAULT_EXPOSED_DOMAINS, DOMAIN +from homeassistant.components.cloud.http_api import validate_language_voice from homeassistant.components.google_assistant.helpers import GoogleEntity from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.websocket_api import ERR_INVALID_FORMAT @@ -1822,17 +1821,14 @@ async def test_tts_info( response = await client.receive_json() assert response["success"] - assert response["result"] == { - "languages": json.loads( - json.dumps( - [ - (language, voice) - for language, voices in TTS_VOICES.items() - for voice in voices - ] - ) - ) - } + assert "languages" in response["result"] + assert all(len(lang) for lang in response["result"]["languages"]) + assert len(response["result"]["languages"]) > 300 + assert ( + len([lang for lang in response["result"]["languages"] if "||" in lang[1]]) > 100 + ) + for lang in response["result"]["languages"]: + assert validate_language_voice(lang[:2]) @pytest.mark.parametrize( diff --git a/tests/components/cloud/test_onboarding.py b/tests/components/cloud/test_onboarding.py new file mode 100644 index 00000000000..142cd90a59c --- /dev/null +++ b/tests/components/cloud/test_onboarding.py @@ -0,0 +1,165 @@ +"""Test the onboarding views.""" + +from http import HTTPStatus +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components import onboarding +from homeassistant.components.cloud import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import register_auth_provider +from tests.typing import ClientSessionGenerator + + +def mock_onboarding_storage(hass_storage, data): + """Mock the onboarding storage.""" + hass_storage[onboarding.STORAGE_KEY] = { + "version": onboarding.STORAGE_VERSION, + "data": data, + } + + +@pytest.fixture(autouse=True) +async def auth_active(hass: HomeAssistant) -> None: + """Ensure auth is always active.""" + await register_auth_provider(hass, {"type": "homeassistant"}) + + +@pytest.fixture(name="setup_cloud", autouse=True) +async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: + """Fixture that sets up cloud.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ( + "post", + "cloud/forgot_password", + {"json": {"email": "hello@bla.com"}}, + ), + ( + "post", + "cloud/login", + {"json": {"email": "my_username", "password": "my_password"}}, + ), + ("post", "cloud/logout", {}), + ("get", "cloud/status", {}), + ], +) +async def test_onboarding_view_after_done( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, + method: str, + view: str, + kwargs: dict[str, Any], +) -> None: + """Test raising after onboarding.""" + mock_onboarding_storage(hass_storage, {"done": [onboarding.const.STEP_USER]}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) + + assert resp.status == 401 + + +async def test_onboarding_cloud_forgot_password( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test cloud forgot password.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + mock_cognito = cloud.auth + + req = await client.post( + "/api/onboarding/cloud/forgot_password", json={"email": "hello@bla.com"} + ) + + assert req.status == HTTPStatus.OK + assert mock_cognito.async_forgot_password.call_count == 1 + + +async def test_onboarding_cloud_login( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test logging out from cloud.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + req = await client.post( + "/api/onboarding/cloud/login", + json={"email": "my_username", "password": "my_password"}, + ) + + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"cloud_pipeline": None, "success": True} + assert cloud.login.call_count == 1 + + +async def test_onboarding_cloud_logout( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test logging out from cloud.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + req = await client.post("/api/onboarding/cloud/logout") + + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"message": "ok"} + assert cloud.logout.call_count == 1 + + +async def test_onboarding_cloud_status( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test logging out from cloud.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + req = await client.get("/api/onboarding/cloud/status") + + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"logged_in": False} diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index 81b10866dff..c920fdac264 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -6,7 +6,8 @@ from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from hass_nabucasa.voice import TTS_VOICES, VoiceError, VoiceTokenError +from hass_nabucasa.voice import VoiceError, VoiceTokenError +from hass_nabucasa.voice_data import TTS_VOICES import pytest import voluptuous as vol @@ -203,7 +204,7 @@ async def test_provider_properties( assert "nl-NL" in engine.supported_languages supported_voices = engine.async_get_supported_voices("nl-NL") assert supported_voices is not None - assert Voice("ColetteNeural", "ColetteNeural") in supported_voices + assert Voice("ColetteNeural", "Colette") in supported_voices supported_voices = engine.async_get_supported_voices("missing_language") assert supported_voices is None diff --git a/tests/components/comelit/conftest.py b/tests/components/comelit/conftest.py index d2d450ccb8d..1e5e85cd26e 100644 --- a/tests/components/comelit/conftest.py +++ b/tests/components/comelit/conftest.py @@ -1,5 +1,7 @@ """Configure tests for Comelit SimpleHome.""" +from copy import deepcopy + import pytest from homeassistant.components.comelit.const import ( @@ -47,10 +49,10 @@ def mock_serial_bridge() -> Generator[AsyncMock]: ), ): bridge = mock_comelit_serial_bridge.return_value - bridge.get_all_devices.return_value = BRIDGE_DEVICE_QUERY + bridge.get_all_devices.return_value = deepcopy(BRIDGE_DEVICE_QUERY) bridge.host = BRIDGE_HOST bridge.port = BRIDGE_PORT - bridge.pin = BRIDGE_PIN + bridge.device_pin = BRIDGE_PIN yield bridge @@ -65,6 +67,7 @@ def mock_serial_bridge_config_entry() -> Generator[MockConfigEntry]: CONF_PIN: BRIDGE_PIN, CONF_TYPE: BRIDGE, }, + entry_id="serial_bridge_config_entry_id", ) @@ -82,10 +85,10 @@ def mock_vedo() -> Generator[AsyncMock]: ), ): vedo = mock_comelit_vedo.return_value - vedo.get_all_areas_and_zones.return_value = VEDO_DEVICE_QUERY + vedo.get_all_areas_and_zones.return_value = deepcopy(VEDO_DEVICE_QUERY) vedo.host = VEDO_HOST vedo.port = VEDO_PORT - vedo.pin = VEDO_PIN + vedo.device_pin = VEDO_PIN vedo.type = VEDO yield vedo @@ -101,4 +104,5 @@ def mock_vedo_config_entry() -> Generator[MockConfigEntry]: CONF_PIN: VEDO_PIN, CONF_TYPE: VEDO, }, + entry_id="vedo_config_entry_id", ) diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index f353ec97628..0cbdaf56bbe 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -29,13 +29,30 @@ VEDO_PIN = 5678 FAKE_PIN = 0000 BRIDGE_DEVICE_QUERY = { - CLIMATE: {}, + CLIMATE: { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val=[ + [221, 0, "U", "M", 50, 0, 0, "U"], + [650, 0, "U", "M", 500, 0, 0, "U"], + [0, 0], + ], + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + }, COVER: { 0: ComelitSerialBridgeObject( index=0, name="Cover0", status=0, - human_status="closed", + human_status="stopped", type="cover", val=0, protected=0, @@ -58,7 +75,20 @@ BRIDGE_DEVICE_QUERY = { power_unit=WATT, ) }, - OTHER: {}, + OTHER: { + 0: ComelitSerialBridgeObject( + index=0, + name="Switch0", + status=0, + human_status="off", + type="other", + val=0, + protected=0, + zone="Bathroom", + power=0.0, + power_unit=WATT, + ), + }, IRRIGATION: {}, SCENARIO: {}, } @@ -69,16 +99,16 @@ VEDO_DEVICE_QUERY = AlarmDataObject( index=0, name="Area0", p1=True, - p2=False, + p2=True, ready=False, - armed=False, + armed=0, alarm=False, alarm_memory=False, sabotage=False, anomaly=False, in_time=False, out_time=False, - human_status=AlarmAreaState.UNKNOWN, + human_status=AlarmAreaState.DISARMED, ) }, alarm_zones={ diff --git a/tests/components/comelit/snapshots/test_climate.ambr b/tests/components/comelit/snapshots/test_climate.ambr new file mode 100644 index 00000000000..e5201067ee1 --- /dev/null +++ b/tests/components/comelit/snapshots/test_climate.ambr @@ -0,0 +1,71 @@ +# serializer version: 1 +# name: test_all_entities[climate.climate0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 5, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.climate0', + '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': 'comelit', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'serial_bridge_config_entry_id-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[climate.climate0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.1, + 'friendly_name': 'Climate0', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 5, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 5.0, + }), + 'context': , + 'entity_id': 'climate.climate0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/comelit/snapshots/test_cover.ambr b/tests/components/comelit/snapshots/test_cover.ambr new file mode 100644 index 00000000000..17189344cd1 --- /dev/null +++ b/tests/components/comelit/snapshots/test_cover.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_all_entities[cover.cover0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.cover0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'serial_bridge_config_entry_id-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[cover.cover0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'shutter', + 'friendly_name': 'Cover0', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.cover0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/comelit/snapshots/test_diagnostics.ambr b/tests/components/comelit/snapshots/test_diagnostics.ambr index c4544f38f52..c9ebf635353 100644 --- a/tests/components/comelit/snapshots/test_diagnostics.ambr +++ b/tests/components/comelit/snapshots/test_diagnostics.ambr @@ -5,13 +5,50 @@ 'devices': list([ dict({ 'clima': list([ + dict({ + '0': dict({ + 'human_status': 'off', + 'name': 'Climate0', + 'power': 0.0, + 'power_unit': 'W', + 'protected': 0, + 'status': 0, + 'val': list([ + list([ + 221, + 0, + 'U', + 'M', + 50, + 0, + 0, + 'U', + ]), + list([ + 650, + 0, + 'U', + 'M', + 500, + 0, + 0, + 'U', + ]), + list([ + 0, + 0, + ]), + ]), + 'zone': 'Living room', + }), + }), ]), }), dict({ 'shutter': list([ dict({ '0': dict({ - 'human_status': 'closed', + 'human_status': 'stopped', 'name': 'Cover0', 'power': 0.0, 'power_unit': 'W', @@ -41,6 +78,18 @@ }), dict({ 'other': list([ + dict({ + '0': dict({ + 'human_status': 'off', + 'name': 'Switch0', + 'power': 0.0, + 'power_unit': 'W', + 'protected': 0, + 'status': 0, + 'val': 0, + 'zone': 'Bathroom', + }), + }), ]), }), dict({ @@ -92,13 +141,13 @@ 'alarm': False, 'alarm_memory': False, 'anomaly': False, - 'armed': False, - 'human_status': 'unknown', + 'armed': 0, + 'human_status': 'disarmed', 'in_time': False, 'name': 'Area0', 'out_time': False, 'p1': True, - 'p2': False, + 'p2': True, 'ready': False, 'sabotage': False, }), diff --git a/tests/components/comelit/snapshots/test_humidifier.ambr b/tests/components/comelit/snapshots/test_humidifier.ambr new file mode 100644 index 00000000000..ffe53d09c5d --- /dev/null +++ b/tests/components/comelit/snapshots/test_humidifier.ambr @@ -0,0 +1,133 @@ +# serializer version: 1 +# name: test_all_entities[humidifier.climate0_dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 90, + 'min_humidity': 10, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.climate0_dehumidifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dehumidifier', + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'dehumidifier', + 'unique_id': 'serial_bridge_config_entry_id-0-dehumidifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[humidifier.climate0_dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'action': , + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 65.0, + 'device_class': 'dehumidifier', + 'friendly_name': 'Climate0 Dehumidifier', + 'humidity': 50.0, + 'max_humidity': 90, + 'min_humidity': 10, + 'mode': 'normal', + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.climate0_dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[humidifier.climate0_humidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 90, + 'min_humidity': 10, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.climate0_humidifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidifier', + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'humidifier', + 'unique_id': 'serial_bridge_config_entry_id-0-humidifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[humidifier.climate0_humidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'action': , + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 65.0, + 'device_class': 'humidifier', + 'friendly_name': 'Climate0 Humidifier', + 'humidity': 50.0, + 'max_humidity': 90, + 'min_humidity': 10, + 'mode': 'normal', + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.climate0_humidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/comelit/snapshots/test_light.ambr b/tests/components/comelit/snapshots/test_light.ambr new file mode 100644 index 00000000000..c60c962e23d --- /dev/null +++ b/tests/components/comelit/snapshots/test_light.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_all_entities[light.light0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.light0', + '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': 'comelit', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'serial_bridge_config_entry_id-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.light0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Light0', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.light0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/comelit/snapshots/test_sensor.ambr b/tests/components/comelit/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..dabae2a1bf0 --- /dev/null +++ b/tests/components/comelit/snapshots/test_sensor.ambr @@ -0,0 +1,76 @@ +# serializer version: 1 +# name: test_all_entities[sensor.zone0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'alarm', + 'armed', + 'open', + 'excluded', + 'faulty', + 'inhibited', + 'isolated', + 'rest', + 'sabotated', + 'unavailable', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zone0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'zone_status', + 'unique_id': 'vedo_config_entry_id-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.zone0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Zone0', + 'options': list([ + 'alarm', + 'armed', + 'open', + 'excluded', + 'faulty', + 'inhibited', + 'isolated', + 'rest', + 'sabotated', + 'unavailable', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.zone0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'rest', + }) +# --- diff --git a/tests/components/comelit/snapshots/test_switch.ambr b/tests/components/comelit/snapshots/test_switch.ambr new file mode 100644 index 00000000000..eddecfabb7a --- /dev/null +++ b/tests/components/comelit/snapshots/test_switch.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_all_entities[switch.switch0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.switch0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'serial_bridge_config_entry_id-other-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.switch0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Switch0', + }), + 'context': , + 'entity_id': 'switch.switch0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/comelit/test_alarm_control_panel.py b/tests/components/comelit/test_alarm_control_panel.py new file mode 100644 index 00000000000..d3feac6ad3b --- /dev/null +++ b/tests/components/comelit/test_alarm_control_panel.py @@ -0,0 +1,155 @@ +"""Tests for Comelit SimpleHome alarm control panel platform.""" + +from unittest.mock import AsyncMock + +from aiocomelit.api import AlarmDataObject, ComelitVedoAreaObject, ComelitVedoZoneObject +from aiocomelit.const import AlarmAreaState, AlarmZoneState +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.alarm_control_panel import ( + ATTR_CODE, + DOMAIN as ALARM_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_DISARM, + AlarmControlPanelState, +) +from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .const import VEDO_PIN + +from tests.common import MockConfigEntry, async_fire_time_changed + +ENTITY_ID = "alarm_control_panel.area0" + + +@pytest.mark.parametrize( + ("human_status", "armed", "alarm_state"), + [ + (AlarmAreaState.DISARMED, 0, AlarmControlPanelState.DISARMED), + (AlarmAreaState.ARMED, 1, AlarmControlPanelState.ARMED_HOME), + (AlarmAreaState.ARMED, 2, AlarmControlPanelState.ARMED_HOME), + (AlarmAreaState.ARMED, 3, AlarmControlPanelState.ARMED_NIGHT), + (AlarmAreaState.ARMED, 4, AlarmControlPanelState.ARMED_AWAY), + (AlarmAreaState.UNKNOWN, 0, STATE_UNAVAILABLE), + ], +) +async def test_entity_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + human_status: AlarmAreaState, + armed: int, + alarm_state: AlarmControlPanelState, +) -> None: + """Test all entities.""" + + await setup_integration(hass, mock_vedo_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == AlarmControlPanelState.DISARMED + + vedo_query = AlarmDataObject( + alarm_areas={ + 0: ComelitVedoAreaObject( + index=0, + name="Area0", + p1=True, + p2=True, + ready=False, + armed=armed, + alarm=False, + alarm_memory=False, + sabotage=False, + anomaly=False, + in_time=False, + out_time=False, + human_status=human_status, + ) + }, + alarm_zones={ + 0: ComelitVedoZoneObject( + index=0, + name="Zone0", + status_api="0x000", + status=0, + human_status=AlarmZoneState.REST, + ) + }, + ) + + mock_vedo.get_all_areas_and_zones.return_value = vedo_query + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == alarm_state + + +@pytest.mark.parametrize( + ("service", "alarm_state"), + [ + (SERVICE_ALARM_DISARM, AlarmControlPanelState.DISARMED), + (SERVICE_ALARM_ARM_AWAY, AlarmControlPanelState.ARMED_AWAY), + (SERVICE_ALARM_ARM_HOME, AlarmControlPanelState.ARMED_HOME), + (SERVICE_ALARM_ARM_NIGHT, AlarmControlPanelState.ARMED_NIGHT), + ], +) +async def test_arming_disarming( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + service: str, + alarm_state: AlarmControlPanelState, +) -> None: + """Test arming and disarming.""" + + await setup_integration(hass, mock_vedo_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == AlarmControlPanelState.DISARMED + + await hass.services.async_call( + ALARM_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_CODE: VEDO_PIN}, + blocking=True, + ) + + mock_vedo.set_zone_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == alarm_state + + +async def test_wrong_code( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: + """Test disarm service with wrong code.""" + + await setup_integration(hass, mock_vedo_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == AlarmControlPanelState.DISARMED + + await hass.services.async_call( + ALARM_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_CODE: 1111}, + blocking=True, + ) + + mock_vedo.set_zone_status.assert_not_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == AlarmControlPanelState.DISARMED diff --git a/tests/components/comelit/test_climate.py b/tests/components/comelit/test_climate.py new file mode 100644 index 00000000000..059d7d27d77 --- /dev/null +++ b/tests/components/comelit/test_climate.py @@ -0,0 +1,282 @@ +"""Tests for Comelit SimpleHome climate platform.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.const import CLIMATE, WATT +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, 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 + +ENTITY_ID = "climate.climate0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_serial_bridge_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + ("val", "mode", "temp"), + [ + ( + [ + [100, 0, "U", "M", 210, 0, 0, "U"], + [650, 0, "O", "M", 500, 0, 0, "N"], + [0, 0], + ], + HVACMode.HEAT, + 21.0, + ), + ( + [ + [100, 1, "U", "A", 210, 1, 0, "O"], + [650, 0, "O", "M", 500, 0, 0, "N"], + [0, 0], + ], + HVACMode.HEAT, + 21.0, + ), + ( + [ + [100, 0, "O", "A", 210, 0, 0, "O"], + [650, 0, "O", "M", 500, 0, 0, "N"], + [0, 0], + ], + HVACMode.OFF, + 21.0, + ), + ], +) +async def test_climate_data_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + val: list[Any, Any], + mode: HVACMode, + temp: float, +) -> None: + """Test climate data update.""" + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val=val, + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == mode + assert state.attributes[ATTR_TEMPERATURE] == temp + + +async def test_climate_data_update_bad_data( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate data update.""" + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val="bad_data", + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + +async def test_climate_set_temperature( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate set temperature service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + # Test set temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 23}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 23.0 + + +async def test_climate_set_temperature_when_off( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate set temperature service when off.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + # Switch climate off + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.OFF + + # Test set temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 23}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.OFF + + +async def test_climate_hvac_mode( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate hvac mode service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.OFF + + +async def test_climate_hvac_mode_when_off( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate hvac mode service when off.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.OFF + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.AUTO}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.AUTO diff --git a/tests/components/comelit/test_coordinator.py b/tests/components/comelit/test_coordinator.py index a8ef82a7e89..49e3164e875 100644 --- a/tests/components/comelit/test_coordinator.py +++ b/tests/components/comelit/test_coordinator.py @@ -43,7 +43,7 @@ async def test_coordinator_data_update_fails( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done() assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/comelit/test_cover.py b/tests/components/comelit/test_cover.py new file mode 100644 index 00000000000..7fb74911cc6 --- /dev/null +++ b/tests/components/comelit/test_cover.py @@ -0,0 +1,161 @@ +"""Tests for Comelit SimpleHome cover platform.""" + +from unittest.mock import AsyncMock, patch + +from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.const import COVER, WATT +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPENING, +) +from homeassistant.const import ATTR_ENTITY_ID, 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 + +ENTITY_ID = "cover.cover0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.COVER]): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_serial_bridge_config_entry.entry_id, + ) + + +async def test_cover_open( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test cover open service.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + # Open cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OPENING + + # Finish opening, update status + mock_serial_bridge.get_all_devices.return_value[COVER] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Cover0", + status=0, + human_status="stopped", + type="cover", + val=0, + protected=0, + zone="Open space", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + +async def test_cover_close( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test cover close and stop service.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + # Close cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_CLOSING + + # Stop cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_CLOSED + + +async def test_cover_stop_if_stopped( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test cover stop service when already stopped.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + # Stop cover while not opening/closing + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_not_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/comelit/test_humidifier.py b/tests/components/comelit/test_humidifier.py new file mode 100644 index 00000000000..448453aadef --- /dev/null +++ b/tests/components/comelit/test_humidifier.py @@ -0,0 +1,292 @@ +"""Tests for Comelit SimpleHome humidifier platform.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.const import CLIMATE, WATT +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.comelit.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + ATTR_MODE, + DOMAIN as HUMIDIFIER_DOMAIN, + MODE_AUTO, + MODE_NORMAL, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "humidifier.climate0_humidifier" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.HUMIDIFIER] + ): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_serial_bridge_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + ("val", "mode", "humidity"), + [ + ( + [ + [100, 0, "U", "M", 210, 0, 0, "U"], + [650, 0, "U", "M", 500, 0, 0, "U"], + [0, 0], + ], + STATE_ON, + 50.0, + ), + ( + [ + [100, 1, "U", "A", 210, 1, 0, "O"], + [650, 1, "U", "A", 500, 1, 0, "O"], + [0, 0], + ], + STATE_ON, + 50.0, + ), + ( + [ + [100, 0, "O", "A", 210, 0, 0, "O"], + [650, 0, "O", "A", 500, 0, 0, "O"], + [0, 0], + ], + STATE_OFF, + 50.0, + ), + ], +) +async def test_humidifier_data_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + val: list[Any, Any], + mode: str, + humidity: float, +) -> None: + """Test humidifier data update.""" + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val=val, + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == mode + assert state.attributes[ATTR_HUMIDITY] == humidity + + +async def test_humidifier_data_update_bad_data( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier data update.""" + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val="bad_data", + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + +async def test_humidifier_set_humidity( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier set humidity service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + # Test set humidity + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HUMIDITY: 23}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 23.0 + + +async def test_humidifier_set_humidity_while_off( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier set humidity service while off.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + # Switch humidifier off + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + # Try setting humidity + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HUMIDITY: 23}, + blocking=True, + ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "humidity_while_off" + + +async def test_humidifier_set_mode( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier set mode service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + assert state.attributes[ATTR_MODE] == MODE_NORMAL + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MODE: MODE_AUTO}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_MODE] == MODE_AUTO + + +async def test_humidifier_set_status( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier set status service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + # Test turn off + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + # Test turn on + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON diff --git a/tests/components/comelit/test_light.py b/tests/components/comelit/test_light.py new file mode 100644 index 00000000000..7c3cd15c135 --- /dev/null +++ b/tests/components/comelit/test_light.py @@ -0,0 +1,76 @@ +"""Tests for Comelit SimpleHome light platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "light.light0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.LIGHT]): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_serial_bridge_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + ("service", "status"), + [ + (SERVICE_TURN_OFF, STATE_OFF), + (SERVICE_TURN_ON, STATE_ON), + (SERVICE_TOGGLE, STATE_ON), + ], +) +async def test_light_set_state( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + service: str, + status: str, +) -> None: + """Test light set state service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + # Test set temperature + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == status diff --git a/tests/components/comelit/test_sensor.py b/tests/components/comelit/test_sensor.py new file mode 100644 index 00000000000..2b857f9c94a --- /dev/null +++ b/tests/components/comelit/test_sensor.py @@ -0,0 +1,90 @@ +"""Tests for Comelit SimpleHome sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from aiocomelit.api import AlarmDataObject, ComelitVedoAreaObject, ComelitVedoZoneObject +from aiocomelit.const import AlarmAreaState, AlarmZoneState +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.const import 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 + +ENTITY_ID = "sensor.zone0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.VEDO_PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_vedo_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_vedo_config_entry.entry_id, + ) + + +async def test_sensor_state_unknown( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: + """Test sensor unknown state.""" + + await setup_integration(hass, mock_vedo_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == AlarmZoneState.REST.value + + vedo_query = AlarmDataObject( + alarm_areas={ + 0: ComelitVedoAreaObject( + index=0, + name="Area0", + p1=True, + p2=True, + ready=False, + armed=True, + alarm=False, + alarm_memory=False, + sabotage=False, + anomaly=False, + in_time=False, + out_time=False, + human_status=AlarmAreaState.UNKNOWN, + ) + }, + alarm_zones={ + 0: ComelitVedoZoneObject( + index=0, + name="Zone0", + status_api="0x000", + status=0, + human_status=AlarmZoneState.UNKNOWN, + ) + }, + ) + + mock_vedo.get_all_areas_and_zones.return_value = vedo_query + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/comelit/test_switch.py b/tests/components/comelit/test_switch.py new file mode 100644 index 00000000000..01efabf6b6f --- /dev/null +++ b/tests/components/comelit/test_switch.py @@ -0,0 +1,76 @@ +"""Tests for Comelit SimpleHome switch platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "switch.switch0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_serial_bridge_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + ("service", "status"), + [ + (SERVICE_TURN_OFF, STATE_OFF), + (SERVICE_TURN_ON, STATE_ON), + (SERVICE_TOGGLE, STATE_ON), + ], +) +async def test_switch_set_state( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + service: str, + status: str, +) -> None: + """Test switch set state service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + # Test set temperature + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == status diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index aa49410aacb..fb7a407cee5 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -331,9 +331,10 @@ async def test_updating_manually( "name": "Test", "command": "echo 10", "payload_on": "1.0", - "payload_off": "0", + "payload_off": "0.0", "value_template": "{{ value | multiply(0.1) }}", - "availability": '{{ states("sensor.input1")=="on" }}', + "availability": '{{ "sensor.input1" | has_value }}', + "icon": 'mdi:{{ states("sensor.input1") }}', } } ] @@ -346,8 +347,7 @@ async def test_availability( freezer: FrozenDateTimeFactory, ) -> None: """Test availability.""" - - hass.states.async_set("sensor.input1", "on") + hass.states.async_set("sensor.input1", STATE_ON) freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) @@ -355,8 +355,9 @@ async def test_availability( entity_state = hass.states.get("binary_sensor.test") assert entity_state assert entity_state.state == STATE_ON + assert entity_state.attributes["icon"] == "mdi:on" - hass.states.async_set("sensor.input1", "off") + hass.states.async_set("sensor.input1", STATE_UNAVAILABLE) await hass.async_block_till_done() with mock_asyncio_subprocess_run(b"0"): freezer.tick(timedelta(minutes=1)) @@ -366,3 +367,64 @@ async def test_availability( entity_state = hass.states.get("binary_sensor.test") assert entity_state assert entity_state.state == STATE_UNAVAILABLE + assert "icon" not in entity_state.attributes + + hass.states.async_set("sensor.input1", STATE_OFF) + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"0"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("binary_sensor.test") + assert entity_state + assert entity_state.state == STATE_OFF + assert entity_state.attributes["icon"] == "mdi:off" + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 10", + "payload_on": "1.0", + "payload_off": "0.0", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + } + } + ] + } + ], +) +async def test_availability_blocks_value_template( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for binary_sensor.test: 'x' is undefined" + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"51\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error not in caplog.text + + entity_state = hass.states.get("binary_sensor.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"50\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index a6e384fdd6b..5010b85ae70 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -371,7 +371,9 @@ async def test_updating_manually( "cover": { "command_state": "echo 10", "name": "Test", - "availability": '{{ states("sensor.input1")=="on" }}', + "value_template": "{{ value }}", + "availability": '{{ "sensor.input1" | has_value }}', + "icon": 'mdi:{{ states("sensor.input1") }}', }, } ] @@ -393,8 +395,9 @@ async def test_availability( entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.state == CoverState.OPEN + assert entity_state.attributes["icon"] == "mdi:on" - hass.states.async_set("sensor.input1", "off") + hass.states.async_set("sensor.input1", STATE_UNAVAILABLE) await hass.async_block_till_done() with mock_asyncio_subprocess_run(b"50\n"): freezer.tick(timedelta(minutes=1)) @@ -404,6 +407,19 @@ async def test_availability( entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.state == STATE_UNAVAILABLE + assert "icon" not in entity_state.attributes + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"25\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("cover.test") + assert entity_state + assert entity_state.state == CoverState.OPEN + assert entity_state.attributes["icon"] == "mdi:off" async def test_icon_template(hass: HomeAssistant) -> None: @@ -455,3 +471,49 @@ async def test_icon_template(hass: HomeAssistant) -> None: entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.attributes.get("icon") == "mdi:icon2" + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "cover": { + "command_state": "echo 10", + "name": "Test", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + }, + } + ] + } + ], +) +async def test_availability_blocks_value_template( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for cover.test: 'x' is undefined" + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"51\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error not in caplog.text + + entity_state = hass.states.get("cover.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"50\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index f7879b334cd..9c619537b94 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -772,17 +772,92 @@ async def test_template_not_error_when_data_is_none( { "sensor": { "name": "Test", - "command": "echo January 17, 2022", - "device_class": "date", - "value_template": "{{ strptime(value, '%B %d, %Y').strftime('%Y-%m-%d') }}", - "availability": '{{ states("sensor.input1")=="on" }}', + "command": 'echo { \\"key\\": \\"value\\" }', + "availability": '{{ "sensor.input1" | has_value }}', + "icon": 'mdi:{{ states("sensor.input1") }}', + "json_attributes": ["key"], } } ] } ], ) -async def test_availability( +async def test_availability_json_attributes_without_value_template( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability.""" + hass.states.async_set("sensor.input1", "on") + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == "unknown" + assert entity_state.attributes["key"] == "value" + assert entity_state.attributes["icon"] == "mdi:on" + + hass.states.async_set("sensor.input1", STATE_UNAVAILABLE) + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"Not A Number"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Unable to parse output as JSON" not in caplog.text + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + assert "key" not in entity_state.attributes + assert "icon" not in entity_state.attributes + + hass.states.async_set("sensor.input1", "on") + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"Not A Number"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Unable to parse output as JSON" in caplog.text + + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + with mock_asyncio_subprocess_run(b'{ "key": "value" }'): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == "unknown" + assert entity_state.attributes["key"] == "value" + assert entity_state.attributes["icon"] == "mdi:on" + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo January 17, 2022", + "device_class": "date", + "value_template": "{{ strptime(value, '%B %d, %Y').strftime('%Y-%m-%d') }}", + "availability": '{{ states("sensor.input1")=="on" }}', + "icon": "mdi:o{{ 'n' if states('sensor.input1')=='on' else 'ff' }}", + } + } + ] + } + ], +) +async def test_availability_with_value_template( hass: HomeAssistant, load_yaml_integration: None, freezer: FrozenDateTimeFactory, @@ -797,6 +872,7 @@ async def test_availability( entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.state == "2022-01-17" + assert entity_state.attributes["icon"] == "mdi:on" hass.states.async_set("sensor.input1", "off") await hass.async_block_till_done() @@ -808,3 +884,141 @@ async def test_availability( entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.state == STATE_UNAVAILABLE + assert "icon" not in entity_state.attributes + + +async def test_template_render_with_availability_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test availability template render with syntax errors.""" + assert await setup.async_setup_component( + hass, + "command_line", + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo {{ states.sensor.input_sensor.state }}", + "availability": "{{ what_the_heck == 2 }}", + } + } + ] + }, + ) + await hass.async_block_till_done() + + hass.states.async_set("sensor.input_sensor", "1") + await hass.async_block_till_done() + + # Give time for template to load + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + # Sensors are unknown if never triggered + state = hass.states.get("sensor.test") + assert state is not None + assert state.state == "1" + + assert ( + "Error rendering availability template for sensor.test: UndefinedError: 'what_the_heck' is undefined" + in caplog.text + ) + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo {{ states.sensor.input_sensor.state }}", + "availability": "{{ value|is_number}}", + "unit_of_measurement": " ", + "state_class": "measurement", + } + } + ] + } + ], +) +async def test_command_template_render_with_availability( + hass: HomeAssistant, load_yaml_integration: None +) -> None: + """Test command template is rendered properly with availability.""" + hass.states.async_set("sensor.input_sensor", "sensor_value") + + # Give time for template to load + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + hass.states.async_set("sensor.input_sensor", "1") + + # Give time for template to load + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == "1" + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo 0", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + }, + } + ] + } + ], +) +async def test_availability_blocks_value_template( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for sensor.test: 'x' is undefined" + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"51\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error not in caplog.text + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"50\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index 6b34cf0fa77..8a8835ceaa0 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -735,7 +735,9 @@ async def test_updating_manually( "command_on": "echo 2", "command_off": "echo 3", "name": "Test", - "availability": '{{ states("sensor.input1")=="on" }}', + "value_template": "{{ value_json == 0 }}", + "availability": '{{ "sensor.input1" | has_value }}', + "icon": 'mdi:{{ states("sensor.input1") }}', }, } ] @@ -749,16 +751,17 @@ async def test_availability( ) -> None: """Test availability.""" - hass.states.async_set("sensor.input1", "on") + hass.states.async_set("sensor.input1", STATE_OFF) freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) entity_state = hass.states.get("switch.test") assert entity_state - assert entity_state.state == STATE_ON + assert entity_state.state == STATE_OFF + assert entity_state.attributes["icon"] == "mdi:off" - hass.states.async_set("sensor.input1", "off") + hass.states.async_set("sensor.input1", STATE_UNAVAILABLE) await hass.async_block_till_done() with mock_asyncio_subprocess_run(b"50\n"): freezer.tick(timedelta(minutes=1)) @@ -768,3 +771,64 @@ async def test_availability( entity_state = hass.states.get("switch.test") assert entity_state assert entity_state.state == STATE_UNAVAILABLE + assert "icon" not in entity_state.attributes + + hass.states.async_set("sensor.input1", STATE_ON) + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"0\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("switch.test") + assert entity_state + assert entity_state.state == STATE_ON + assert entity_state.attributes["icon"] == "mdi:on" + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "switch": { + "command_state": "echo 1", + "command_on": "echo 2", + "command_off": "echo 3", + "name": "Test", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + }, + } + ] + } + ], +) +async def test_availability_blocks_value_template( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for switch.test: 'x' is undefined" + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"51\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error not in caplog.text + + entity_state = hass.states.get("switch.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"50\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index ce10a36c42c..6784866ea4b 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -8,11 +8,12 @@ from unittest.mock import ANY, AsyncMock, patch from aiohttp.test_utils import TestClient from freezegun.api import FrozenDateTimeFactory import pytest +from pytest_unordered import unordered import voluptuous as vol from homeassistant import config_entries as core_ce, data_entry_flow, loader from homeassistant.components.config import config_entries -from homeassistant.config_entries import HANDLERS, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType @@ -34,13 +35,6 @@ from tests.common import ( from tests.typing import ClientSessionGenerator, WebSocketGenerator -@pytest.fixture -def clear_handlers() -> Generator[None]: - """Clear config entry handlers.""" - with patch.dict(HANDLERS, clear=True): - yield - - @pytest.fixture(autouse=True) def mock_test_component(hass: HomeAssistant) -> None: """Ensure a component called 'test' exists.""" @@ -74,7 +68,7 @@ def mock_flow() -> Generator[None]: @pytest.mark.usefixtures("freezer") -@pytest.mark.usefixtures("clear_handlers", "mock_flow") +@pytest.mark.usefixtures("mock_flow") async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: """Test get entries.""" mock_integration(hass, MockModule("comp1")) @@ -358,7 +352,7 @@ async def test_reload_entry_in_setup_retry( entry.add_to_hass(hass) hass.config.components.add("comp") - with patch.dict(HANDLERS, {"comp": ConfigFlow, "test": ConfigFlow}): + with mock_config_flow("comp", ConfigFlow), mock_config_flow("test", ConfigFlow): resp = await client.post( f"/api/config/config_entries/entry/{entry.entry_id}/reload" ) @@ -422,7 +416,7 @@ async def test_initialize_flow(hass: HomeAssistant, client: TestClient) -> None: errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test", "show_advanced_options": True}, @@ -471,7 +465,7 @@ async def test_initialize_flow_unmet_dependency( async def async_step_user(self, user_input=None): pass - with patch.dict(HANDLERS, {"test2": TestFlow}): + with mock_config_flow("test2", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test2", "show_advanced_options": True}, @@ -502,7 +496,7 @@ async def test_initialize_flow_unauth( errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -519,7 +513,7 @@ async def test_abort(hass: HomeAssistant, client: TestClient) -> None: async def async_step_user(self, user_input=None): return self.async_abort(reason="bla") - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -552,7 +546,7 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None: title="Test Entry", data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -620,7 +614,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: title=user_input["user_title"], data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -638,7 +632,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: "preview": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/flow/{flow_id}", json={"user_title": "user-title"}, @@ -707,7 +701,7 @@ async def test_continue_flow_unauth( title=user_input["user_title"], data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -774,7 +768,7 @@ async def test_get_progress_index( assert self._get_reconfigure_entry() is entry return await self.async_step_account() - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): form_hassio = await hass.config_entries.flow.async_init( "test", context={"source": core_ce.SOURCE_HASSIO} ) @@ -838,7 +832,7 @@ async def test_get_progress_flow(hass: HomeAssistant, client: TestClient) -> Non errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -874,7 +868,7 @@ async def test_get_progress_flow_unauth( errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -889,6 +883,256 @@ async def test_get_progress_flow_unauth( assert resp2.status == HTTPStatus.UNAUTHORIZED +async def test_get_progress_subscribe( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test querying for the flows that are in progress.""" + assert await async_setup_component(hass, "config", {}) + mock_platform(hass, "test.config_flow", None) + ws_client = await hass_ws_client(hass) + + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + + entry = MockConfigEntry(domain="test", title="Test", entry_id="1234") + entry.add_to_hass(hass) + + class TestFlow(core_ce.ConfigFlow): + VERSION = 5 + + async def async_step_bluetooth( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a bluetooth discovery.""" + return self.async_abort(reason="already_configured") + + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a Hass.io discovery.""" + return await self.async_step_account() + + async def async_step_account(self, user_input: dict[str, Any] | None = None): + """Show a form to the user.""" + return self.async_show_form(step_id="account") + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Handle a config flow initialized by the user.""" + return await self.async_step_account() + + async def async_step_reauth(self, user_input: dict[str, Any] | None = None): + """Handle a reauthentication flow.""" + nonlocal entry + assert self._get_reauth_entry() is entry + return await self.async_step_account() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ): + """Handle a reconfiguration flow initialized by the user.""" + nonlocal entry + assert self._get_reconfigure_entry() is entry + return await self.async_step_account() + + await ws_client.send_json({"id": 1, "type": "config_entries/flow/subscribe"}) + response = await ws_client.receive_json() + assert response == {"id": 1, "event": [], "type": "event"} + response = await ws_client.receive_json() + assert response == {"id": 1, "result": None, "success": True, "type": "result"} + + flow_context = { + "bluetooth": {"source": core_ce.SOURCE_BLUETOOTH}, + "hassio": {"source": core_ce.SOURCE_HASSIO}, + "user": {"source": core_ce.SOURCE_USER}, + "reauth": {"source": core_ce.SOURCE_REAUTH, "entry_id": "1234"}, + "reconfigure": {"source": core_ce.SOURCE_RECONFIGURE, "entry_id": "1234"}, + } + forms = {} + + with mock_config_flow("test", TestFlow): + for key, context in flow_context.items(): + forms[key] = await hass.config_entries.flow.async_init( + "test", context=context + ) + + assert forms["bluetooth"]["type"] == data_entry_flow.FlowResultType.ABORT + for key in ("hassio", "user", "reauth", "reconfigure"): + assert forms[key]["type"] == data_entry_flow.FlowResultType.FORM + assert forms[key]["step_id"] == "account" + + for key in ("hassio", "user", "reauth", "reconfigure"): + hass.config_entries.flow.async_abort(forms[key]["flow_id"]) + + # Uninitialized flows and flows with SOURCE_USER and SOURCE_RECONFIGURE + # should be filtered out + for key in ("hassio", "reauth"): + response = await ws_client.receive_json() + assert response == { + "event": [ + { + "flow": { + "flow_id": forms[key]["flow_id"], + "handler": "test", + "step_id": "account", + "context": flow_context[key], + }, + "flow_id": forms[key]["flow_id"], + "type": "added", + } + ], + "id": 1, + "type": "event", + } + for key in ("hassio", "reauth"): + response = await ws_client.receive_json() + assert response == { + "event": [ + { + "flow_id": forms[key]["flow_id"], + "type": "removed", + } + ], + "id": 1, + "type": "event", + } + + +async def test_get_progress_subscribe_in_progress( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test querying for the flows that are in progress.""" + assert await async_setup_component(hass, "config", {}) + mock_platform(hass, "test.config_flow", None) + ws_client = await hass_ws_client(hass) + + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + + entry = MockConfigEntry(domain="test", title="Test", entry_id="1234") + entry.add_to_hass(hass) + + class TestFlow(core_ce.ConfigFlow): + VERSION = 5 + + async def async_step_bluetooth( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a bluetooth discovery.""" + return self.async_abort(reason="already_configured") + + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a Hass.io discovery.""" + return await self.async_step_account() + + async def async_step_account(self, user_input: dict[str, Any] | None = None): + """Show a form to the user.""" + return self.async_show_form(step_id="account") + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Handle a config flow initialized by the user.""" + return await self.async_step_account() + + async def async_step_reauth(self, user_input: dict[str, Any] | None = None): + """Handle a reauthentication flow.""" + nonlocal entry + assert self._get_reauth_entry() is entry + return await self.async_step_account() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ): + """Handle a reconfiguration flow initialized by the user.""" + nonlocal entry + assert self._get_reconfigure_entry() is entry + return await self.async_step_account() + + flow_context = { + "bluetooth": {"source": core_ce.SOURCE_BLUETOOTH}, + "hassio": {"source": core_ce.SOURCE_HASSIO}, + "user": {"source": core_ce.SOURCE_USER}, + "reauth": {"source": core_ce.SOURCE_REAUTH, "entry_id": "1234"}, + "reconfigure": {"source": core_ce.SOURCE_RECONFIGURE, "entry_id": "1234"}, + } + forms = {} + + with mock_config_flow("test", TestFlow): + for key, context in flow_context.items(): + forms[key] = await hass.config_entries.flow.async_init( + "test", context=context + ) + + assert forms["bluetooth"]["type"] == data_entry_flow.FlowResultType.ABORT + for key in ("hassio", "user", "reauth", "reconfigure"): + assert forms[key]["type"] == data_entry_flow.FlowResultType.FORM + assert forms[key]["step_id"] == "account" + + await ws_client.send_json({"id": 1, "type": "config_entries/flow/subscribe"}) + + # Uninitialized flows and flows with SOURCE_USER and SOURCE_RECONFIGURE + # should be filtered out + responses = [] + responses.append(await ws_client.receive_json()) + assert responses == [ + { + "event": unordered( + [ + { + "flow": { + "flow_id": forms[key]["flow_id"], + "handler": "test", + "step_id": "account", + "context": flow_context[key], + }, + "flow_id": forms[key]["flow_id"], + "type": None, + } + for key in ("hassio", "reauth") + ] + ), + "id": 1, + "type": "event", + } + ] + + response = await ws_client.receive_json() + assert response == {"id": ANY, "result": None, "success": True, "type": "result"} + + for key in ("hassio", "user", "reauth", "reconfigure"): + hass.config_entries.flow.async_abort(forms[key]["flow_id"]) + + for key in ("hassio", "reauth"): + response = await ws_client.receive_json() + assert response == { + "event": [ + { + "flow_id": forms[key]["flow_id"], + "type": "removed", + } + ], + "id": 1, + "type": "event", + } + + +async def test_get_progress_subscribe_unauth( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_admin_user: MockUser +) -> None: + """Test we can't subscribe to flows.""" + assert await async_setup_component(hass, "config", {}) + hass_admin_user.groups = [] + ws_client = await hass_ws_client(hass) + + await ws_client.send_json({"id": 5, "type": "config_entries/flow/subscribe"}) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"]["code"] == "unauthorized" + + async def test_options_flow(hass: HomeAssistant, client: TestClient) -> None: """Test we can change options.""" @@ -918,7 +1162,7 @@ async def test_options_flow(hass: HomeAssistant, client: TestClient) -> None: ).add_to_hass(hass) entry = hass.config_entries.async_entries()[0] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): url = "/api/config/config_entries/options/flow" resp = await client.post(url, json={"handler": entry.entry_id}) @@ -980,7 +1224,7 @@ async def test_options_flow_unauth( hass_admin_user.groups = [] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await getattr(client, method)(endpoint, json={"handler": entry.entry_id}) assert resp.status == HTTPStatus.UNAUTHORIZED @@ -1017,7 +1261,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client: TestClient) -> ).add_to_hass(hass) entry = hass.config_entries.async_entries()[0] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): url = "/api/config/config_entries/options/flow" resp = await client.post(url, json={"handler": entry.entry_id}) @@ -1035,7 +1279,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client: TestClient) -> "preview": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/options/flow/{flow_id}", json={"enabled": True}, @@ -1092,7 +1336,7 @@ async def test_options_flow_with_invalid_data( ).add_to_hass(hass) entry = hass.config_entries.async_entries()[0] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): url = "/api/config/config_entries/options/flow" resp = await client.post(url, json={"handler": entry.entry_id}) @@ -1118,7 +1362,7 @@ async def test_options_flow_with_invalid_data( "preview": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/options/flow/{flow_id}", json={"choices": ["valid", "invalid"]}, @@ -1812,7 +2056,7 @@ async def test_ignore_flow( ws_client = await hass_ws_client(hass) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): result = await hass.config_entries.flow.async_init( "test", context={"source": core_ce.SOURCE_USER} | flow_context ) @@ -1861,7 +2105,7 @@ async def test_ignore_flow_nonexisting( assert response["error"]["code"] == "not_found" -@pytest.mark.usefixtures("clear_handlers", "freezer") +@pytest.mark.usefixtures("freezer") async def test_get_matching_entries_ws( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -2313,7 +2557,6 @@ async def test_get_matching_entries_ws( assert response["success"] is False -@pytest.mark.usefixtures("clear_handlers") async def test_subscribe_entries_ws( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -2532,7 +2775,6 @@ async def test_subscribe_entries_ws( ] -@pytest.mark.usefixtures("clear_handlers") async def test_subscribe_entries_ws_filtered( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -2792,7 +3034,7 @@ async def test_flow_with_multiple_schema_errors( ), ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -2834,7 +3076,7 @@ async def test_flow_with_multiple_schema_errors_base( ), ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -2893,7 +3135,7 @@ async def test_supports_reconfigure( data={"secret": "account_token"}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test", "entry_id": "1"}, @@ -2915,7 +3157,7 @@ async def test_supports_reconfigure( "errors": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/flow/{flow_id}", json={}, @@ -2953,7 +3195,7 @@ async def test_does_not_support_reconfigure( title="Test Entry", data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test", "entry_id": "1"}, diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index 2e3de33d808..ea7a65f25d3 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -12,7 +12,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryDisabler from homeassistant.helpers.entity_registry import ( - RegistryEntry, RegistryEntryDisabler, RegistryEntryHider, ) @@ -23,6 +22,7 @@ from tests.common import ( MockConfigEntry, MockEntity, MockEntityPlatform, + RegistryEntryWithDefaults, mock_registry, ) from tests.typing import MockHAClientWebSocket, WebSocketGenerator @@ -45,13 +45,13 @@ async def test_list_entities( mock_registry( hass, { - "test_domain.name": RegistryEntry( + "test_domain.name": RegistryEntryWithDefaults( entity_id="test_domain.name", unique_id="1234", platform="test_platform", name="Hello World", ), - "test_domain.no_name": RegistryEntry( + "test_domain.no_name": RegistryEntryWithDefaults( entity_id="test_domain.no_name", unique_id="6789", platform="test_platform", @@ -117,13 +117,13 @@ async def test_list_entities( mock_registry( hass, { - "test_domain.name": RegistryEntry( + "test_domain.name": RegistryEntryWithDefaults( entity_id="test_domain.name", unique_id="1234", platform="test_platform", name="Hello World", ), - "test_domain.name_2": RegistryEntry( + "test_domain.name_2": RegistryEntryWithDefaults( entity_id="test_domain.name_2", unique_id="6789", platform="test_platform", @@ -169,7 +169,7 @@ async def test_list_entities_for_display( mock_registry( hass, { - "test_domain.test": RegistryEntry( + "test_domain.test": RegistryEntryWithDefaults( area_id="area52", device_id="device123", entity_category=EntityCategory.DIAGNOSTIC, @@ -181,7 +181,7 @@ async def test_list_entities_for_display( translation_key="translations_galore", unique_id="1234", ), - "test_domain.nameless": RegistryEntry( + "test_domain.nameless": RegistryEntryWithDefaults( area_id="area52", device_id="device123", entity_id="test_domain.nameless", @@ -191,7 +191,7 @@ async def test_list_entities_for_display( platform="test_platform", unique_id="2345", ), - "test_domain.renamed": RegistryEntry( + "test_domain.renamed": RegistryEntryWithDefaults( area_id="area52", device_id="device123", entity_id="test_domain.renamed", @@ -201,31 +201,31 @@ async def test_list_entities_for_display( platform="test_platform", unique_id="3456", ), - "test_domain.boring": RegistryEntry( + "test_domain.boring": RegistryEntryWithDefaults( entity_id="test_domain.boring", platform="test_platform", unique_id="4567", ), - "test_domain.disabled": RegistryEntry( + "test_domain.disabled": RegistryEntryWithDefaults( disabled_by=RegistryEntryDisabler.USER, entity_id="test_domain.disabled", hidden_by=RegistryEntryHider.USER, platform="test_platform", unique_id="789A", ), - "test_domain.hidden": RegistryEntry( + "test_domain.hidden": RegistryEntryWithDefaults( entity_id="test_domain.hidden", hidden_by=RegistryEntryHider.USER, platform="test_platform", unique_id="89AB", ), - "sensor.default_precision": RegistryEntry( + "sensor.default_precision": RegistryEntryWithDefaults( entity_id="sensor.default_precision", options={"sensor": {"suggested_display_precision": 0}}, platform="test_platform", unique_id="9ABC", ), - "sensor.user_precision": RegistryEntry( + "sensor.user_precision": RegistryEntryWithDefaults( entity_id="sensor.user_precision", options={ "sensor": {"display_precision": 0, "suggested_display_precision": 1} @@ -303,7 +303,7 @@ async def test_list_entities_for_display( mock_registry( hass, { - "test_domain.test": RegistryEntry( + "test_domain.test": RegistryEntryWithDefaults( area_id="area52", device_id="device123", entity_id="test_domain.test", @@ -312,7 +312,7 @@ async def test_list_entities_for_display( platform="test_platform", unique_id="1234", ), - "test_domain.name_2": RegistryEntry( + "test_domain.name_2": RegistryEntryWithDefaults( entity_id="test_domain.name_2", has_entity_name=True, original_name=Unserializable(), @@ -348,7 +348,7 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) -> mock_registry( hass, { - "test_domain.name": RegistryEntry( + "test_domain.name": RegistryEntryWithDefaults( entity_id="test_domain.name", unique_id="1234", platform="test_platform", @@ -356,7 +356,7 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) -> created_at=name_created_at, modified_at=name_created_at, ), - "test_domain.no_name": RegistryEntry( + "test_domain.no_name": RegistryEntryWithDefaults( entity_id="test_domain.no_name", unique_id="6789", platform="test_platform", @@ -445,7 +445,7 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket) mock_registry( hass, { - "test_domain.name": RegistryEntry( + "test_domain.name": RegistryEntryWithDefaults( entity_id="test_domain.name", unique_id="1234", platform="test_platform", @@ -453,7 +453,7 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket) created_at=name_created_at, modified_at=name_created_at, ), - "test_domain.no_name": RegistryEntry( + "test_domain.no_name": RegistryEntryWithDefaults( entity_id="test_domain.no_name", unique_id="6789", platform="test_platform", @@ -545,7 +545,7 @@ async def test_update_entity( registry = mock_registry( hass, { - "test_domain.world": RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" @@ -1009,7 +1009,7 @@ async def test_update_entity_no_changes( mock_registry( hass, { - "test_domain.world": RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" @@ -1110,7 +1110,7 @@ async def test_update_entity_id( mock_registry( hass, { - "test_domain.world": RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" @@ -1179,13 +1179,13 @@ async def test_update_existing_entity_id( mock_registry( hass, { - "test_domain.world": RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" platform="test_platform", ), - "test_domain.planet": RegistryEntry( + "test_domain.planet": RegistryEntryWithDefaults( entity_id="test_domain.planet", unique_id="2345", # Using component.async_add_entities is equal to platform "domain" @@ -1217,7 +1217,7 @@ async def test_update_invalid_entity_id( mock_registry( hass, { - "test_domain.world": RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" @@ -1249,7 +1249,7 @@ async def test_remove_entity( registry = mock_registry( hass, { - "test_domain.world": RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index d7b3531c658..c9e72ae5a03 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -139,6 +139,48 @@ async def test_unknown_llm_api( assert exc_info.value.as_conversation_result().as_dict() == snapshot +async def test_multiple_llm_apis( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test when we reference an LLM API.""" + + class MyTool(llm.Tool): + """Test tool.""" + + name = "test_tool" + description = "Test function" + parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + + class MyAPI(llm.API): + """Test API.""" + + async def async_get_api_instance( + self, llm_context: llm.LLMContext + ) -> llm.APIInstance: + """Return a list of tools.""" + return llm.APIInstance(self, "My API Prompt", llm_context, [MyTool()]) + + api = MyAPI(hass=hass, id="my-api", name="Test") + llm.async_register_api(hass, api) + + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=["assist", "my-api"], + user_llm_prompt=None, + ) + + assert chat_log.llm_api + assert chat_log.llm_api.api.id == "assist|my-api" + + async def test_template_error( hass: HomeAssistant, mock_conversation_input: ConversationInput, diff --git a/tests/components/cover/test_reproduce_state.py b/tests/components/cover/test_reproduce_state.py index 57fc5aed5e9..dfc22abac91 100644 --- a/tests/components/cover/test_reproduce_state.py +++ b/tests/components/cover/test_reproduce_state.py @@ -178,6 +178,22 @@ async def test_reproducing_states( | CoverEntityFeature.OPEN, }, ) + hass.states.async_set( + "cover.closed_supports_all_features", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + ATTR_CURRENT_TILT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.STOP + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION, + }, + ) hass.states.async_set( "cover.tilt_only_open", CoverState.OPEN, @@ -249,6 +265,14 @@ async def test_reproducing_states( await async_reproduce_state( hass, [ + State( + "cover.closed_supports_all_features", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + ATTR_CURRENT_TILT_POSITION: 0, + }, + ), State("cover.entity_close", CoverState.CLOSED), State("cover.closed_only_supports_close_open", CoverState.CLOSED), State("cover.closed_only_supports_tilt_close_open", CoverState.CLOSED), @@ -364,6 +388,11 @@ async def test_reproducing_states( await async_reproduce_state( hass, [ + State( + "cover.closed_supports_all_features", + CoverState.CLOSED, + {ATTR_CURRENT_POSITION: 0, ATTR_CURRENT_TILT_POSITION: 50}, + ), State("cover.entity_close", CoverState.OPEN), State( "cover.closed_only_supports_close_open", @@ -458,7 +487,6 @@ async def test_reproducing_states( valid_close_calls = [ {"entity_id": "cover.entity_open"}, {"entity_id": "cover.entity_open_attr"}, - {"entity_id": "cover.entity_entirely_open"}, {"entity_id": "cover.open_only_supports_close_open"}, {"entity_id": "cover.open_missing_all_features"}, ] @@ -481,11 +509,8 @@ async def test_reproducing_states( valid_open_calls.remove(call.data) valid_close_tilt_calls = [ - {"entity_id": "cover.entity_open_tilt"}, - {"entity_id": "cover.entity_entirely_open"}, {"entity_id": "cover.tilt_only_open"}, {"entity_id": "cover.entity_open_attr"}, - {"entity_id": "cover.tilt_only_tilt_position_100"}, {"entity_id": "cover.open_only_supports_tilt_close_open"}, ] assert len(close_tilt_calls) == len(valid_close_tilt_calls) @@ -495,9 +520,7 @@ async def test_reproducing_states( valid_close_tilt_calls.remove(call.data) valid_open_tilt_calls = [ - {"entity_id": "cover.entity_close_tilt"}, {"entity_id": "cover.tilt_only_closed"}, - {"entity_id": "cover.tilt_only_tilt_position_0"}, {"entity_id": "cover.closed_only_supports_tilt_close_open"}, ] assert len(open_tilt_calls) == len(valid_open_tilt_calls) @@ -523,6 +546,14 @@ async def test_reproducing_states( "entity_id": "cover.open_only_supports_position", ATTR_POSITION: 0, }, + { + "entity_id": "cover.closed_supports_all_features", + ATTR_POSITION: 0, + }, + { + "entity_id": "cover.entity_entirely_open", + ATTR_POSITION: 0, + }, ] assert len(position_calls) == len(valid_position_calls) for call in position_calls: @@ -551,7 +582,34 @@ async def test_reproducing_states( "entity_id": "cover.tilt_partial_open_only_supports_tilt_position", ATTR_TILT_POSITION: 70, }, + { + "entity_id": "cover.closed_supports_all_features", + ATTR_TILT_POSITION: 50, + }, + { + "entity_id": "cover.entity_close_tilt", + ATTR_TILT_POSITION: 100, + }, + { + "entity_id": "cover.entity_open_tilt", + ATTR_TILT_POSITION: 0, + }, + { + "entity_id": "cover.entity_entirely_open", + ATTR_TILT_POSITION: 0, + }, + { + "entity_id": "cover.tilt_only_tilt_position_100", + ATTR_TILT_POSITION: 0, + }, + { + "entity_id": "cover.tilt_only_tilt_position_0", + ATTR_TILT_POSITION: 100, + }, ] + for call in position_tilt_calls: + if ATTR_TILT_POSITION not in call.data: + continue assert len(position_tilt_calls) == len(valid_position_tilt_calls) for call in position_tilt_calls: assert call.domain == "cover" diff --git a/tests/components/demo/test_light.py b/tests/components/demo/test_light.py index b39b09d9307..af9006f97cc 100644 --- a/tests/components/demo/test_light.py +++ b/tests/components/demo/test_light.py @@ -80,7 +80,7 @@ async def test_state_attributes(hass: HomeAssistant) -> None: SERVICE_TURN_ON, { ATTR_ENTITY_ID: ENTITY_LIGHT, - ATTR_EFFECT: "none", + ATTR_EFFECT: "off", ATTR_COLOR_TEMP_KELVIN: 2500, }, blocking=True, @@ -90,7 +90,7 @@ async def test_state_attributes(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) == 2500 assert state.attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN) == 6535 assert state.attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN) == 2000 - assert state.attributes.get(ATTR_EFFECT) == "none" + assert state.attributes.get(ATTR_EFFECT) == "off" await hass.services.async_call( LIGHT_DOMAIN, diff --git a/tests/components/derivative/test_config_flow.py b/tests/components/derivative/test_config_flow.py index efdde93173c..3f27d2366a5 100644 --- a/tests/components/derivative/test_config_flow.py +++ b/tests/components/derivative/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import selector -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("platform", ["sensor"]) @@ -64,17 +64,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: assert config_entry.title == "My derivative" -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - @pytest.mark.parametrize("platform", ["sensor"]) async def test_options(hass: HomeAssistant, platform) -> None: """Test reconfiguring.""" @@ -104,10 +93,10 @@ async def test_options(hass: HomeAssistant, platform) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "round") == 1.0 - assert get_suggested(schema, "time_window") == {"seconds": 0.0} - assert get_suggested(schema, "unit_prefix") == "k" - assert get_suggested(schema, "unit_time") == "min" + assert get_schema_suggested_value(schema, "round") == 1.0 + assert get_schema_suggested_value(schema, "time_window") == {"seconds": 0.0} + assert get_schema_suggested_value(schema, "unit_prefix") == "k" + assert get_schema_suggested_value(schema, "unit_time") == "min" source = schema["source"] assert isinstance(source, selector.EntitySelector) diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index fa1e65ded51..e792d239d59 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -146,12 +146,14 @@ class MockTrackerEntity(TrackerEntity): location_name: str | None = None, latitude: float | None = None, longitude: float | None = None, + location_accuracy: float = 0, ) -> None: """Initialize entity.""" self._battery_level = battery_level self._location_name = location_name self._latitude = latitude self._longitude = longitude + self._location_accuracy = location_accuracy @property def battery_level(self) -> int | None: @@ -181,6 +183,11 @@ class MockTrackerEntity(TrackerEntity): """Return longitude value of the device.""" return self._longitude + @property + def location_accuracy(self) -> float: + """Return the accuracy of the location in meters.""" + return self._location_accuracy + @pytest.fixture(name="battery_level") def battery_level_fixture() -> int | None: @@ -206,6 +213,12 @@ def longitude_fixture() -> float | None: return None +@pytest.fixture(name="location_accuracy") +def accuracy_fixture() -> float: + """Return the location accuracy of the entity for the test.""" + return 0 + + @pytest.fixture(name="tracker_entity") def tracker_entity_fixture( entity_id: str, @@ -213,6 +226,7 @@ def tracker_entity_fixture( location_name: str | None, latitude: float | None, longitude: float | None, + location_accuracy: float = 0, ) -> MockTrackerEntity: """Create a test tracker entity.""" entity = MockTrackerEntity( @@ -220,6 +234,7 @@ def tracker_entity_fixture( location_name=location_name, latitude=latitude, longitude=longitude, + location_accuracy=location_accuracy, ) entity.entity_id = entity_id return entity @@ -513,6 +528,7 @@ def test_tracker_entity() -> None: assert entity.battery_level is None assert entity.should_poll is False assert entity.force_update is True + assert entity.location_accuracy == 0 class MockEntity(TrackerEntity): """Mock tracker class.""" diff --git a/tests/components/device_tracker/test_device_trigger.py b/tests/components/device_tracker/test_device_trigger.py index ebff89e1a15..860c470fc37 100644 --- a/tests/components/device_tracker/test_device_trigger.py +++ b/tests/components/device_tracker/test_device_trigger.py @@ -33,21 +33,19 @@ HOME_LONGITUDE = -117.237561 @pytest.fixture(autouse=True) -def setup_zone(hass: HomeAssistant) -> None: +async def setup_zone(hass: HomeAssistant) -> None: """Create test zone.""" - hass.loop.run_until_complete( - async_setup_component( - hass, - zone.DOMAIN, - { - "zone": { - "name": "test", - "latitude": HOME_LATITUDE, - "longitude": HOME_LONGITUDE, - "radius": 250, - } - }, - ) + await async_setup_component( + hass, + zone.DOMAIN, + { + "zone": { + "name": "test", + "latitude": HOME_LATITUDE, + "longitude": HOME_LONGITUDE, + "radius": 250, + } + }, ) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index ea07365bd2f..94e1803a92d 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -25,7 +25,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import discovery -from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -34,6 +33,7 @@ from . import common from .common import MockScanner, mock_legacy_device_tracker_setup from tests.common import ( + RegistryEntryWithDefaults, assert_setup_component, async_fire_time_changed, mock_registry, @@ -400,7 +400,7 @@ async def test_see_service_guard_config_entry( mock_registry( hass, { - entity_id: RegistryEntry( + entity_id: RegistryEntryWithDefaults( entity_id=entity_id, unique_id=1, platform=const.DOMAIN ) }, diff --git a/tests/components/devolo_home_control/mocks.py b/tests/components/devolo_home_control/mocks.py index d611c73cf2c..24f4e64ffe6 100644 --- a/tests/components/devolo_home_control/mocks.py +++ b/tests/components/devolo_home_control/mocks.py @@ -1,5 +1,6 @@ """Mocks for tests.""" +from datetime import UTC from typing import Any from unittest.mock import MagicMock @@ -28,6 +29,7 @@ class BinarySensorPropertyMock(BinarySensorProperty): def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called """Initialize the mock.""" self._logger = MagicMock() + self._timezone = UTC self.element_uid = "Test" self.key_count = 1 self.sensor_type = "door" @@ -41,6 +43,7 @@ class BinarySwitchPropertyMock(BinarySwitchProperty): def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called """Initialize the mock.""" self._logger = MagicMock() + self._timezone = UTC self.element_uid = "Test" self.state = False @@ -51,6 +54,7 @@ class ConsumptionPropertyMock(ConsumptionProperty): def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called """Initialize the mock.""" self._logger = MagicMock() + self._timezone = UTC self.element_uid = "devolo.Meter:Test" self.current_unit = "W" self.total_unit = "kWh" @@ -68,6 +72,7 @@ class MultiLevelSensorPropertyMock(MultiLevelSensorProperty): self._unit = "°C" self._value = 20 self._logger = MagicMock() + self._timezone = UTC class BrightnessSensorPropertyMock(MultiLevelSensorProperty): @@ -80,6 +85,7 @@ class BrightnessSensorPropertyMock(MultiLevelSensorProperty): self._unit = "%" self._value = 20 self._logger = MagicMock() + self._timezone = UTC class MultiLevelSwitchPropertyMock(MultiLevelSwitchProperty): @@ -92,6 +98,7 @@ class MultiLevelSwitchPropertyMock(MultiLevelSwitchProperty): self.max = 24 self._value = 20 self._logger = MagicMock() + self._timezone = UTC class SirenPropertyMock(MultiLevelSwitchProperty): @@ -105,6 +112,7 @@ class SirenPropertyMock(MultiLevelSwitchProperty): self.switch_type = "tone" self._value = 0 self._logger = MagicMock() + self._timezone = UTC class SettingsMock(SettingsProperty): @@ -113,6 +121,7 @@ class SettingsMock(SettingsProperty): def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called """Initialize the mock.""" self._logger = MagicMock() + self._timezone = UTC self.name = "Test" self.zone = "Test" self.tone = 1 diff --git a/tests/components/devolo_home_network/mock.py b/tests/components/devolo_home_network/mock.py index 82bf3e5ad76..6c0ea9fc6b5 100644 --- a/tests/components/devolo_home_network/mock.py +++ b/tests/components/devolo_home_network/mock.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock from devolo_plc_api.device import Device from devolo_plc_api.device_api.deviceapi import DeviceApi +from devolo_plc_api.exceptions.device import DevicePasswordProtected from devolo_plc_api.plcnet_api.plcnetapi import PlcNetApi import httpx from zeroconf import Zeroconf @@ -64,6 +65,7 @@ class MockDevice(Device): return_value=FIRMWARE_UPDATE_AVAILABLE ) self.device.async_get_led_setting = AsyncMock(return_value=False) + self.device.async_set_led_setting = AsyncMock(return_value=True) self.device.async_restart = AsyncMock(return_value=True) self.device.async_uptime = AsyncMock(return_value=UPTIME) self.device.async_start_wps = AsyncMock(return_value=True) @@ -71,6 +73,7 @@ class MockDevice(Device): return_value=CONNECTED_STATIONS ) self.device.async_get_wifi_guest_access = AsyncMock(return_value=GUEST_WIFI) + self.device.async_set_wifi_guest_access = AsyncMock(return_value=True) self.device.async_get_wifi_neighbor_access_points = AsyncMock( return_value=NEIGHBOR_ACCESS_POINTS ) @@ -79,3 +82,16 @@ class MockDevice(Device): self.plcnet.async_get_network_overview = AsyncMock(return_value=PLCNET) self.plcnet.async_identify_device_start = AsyncMock(return_value=True) self.plcnet.async_pair_device = AsyncMock(return_value=True) + + +class MockDeviceWrongPassword(MockDevice): + """Mock of a devolo Home Network device, that always complains about a wrong password.""" + + def __init__( + self, + ip: str, + zeroconf_instance: AsyncZeroconf | Zeroconf | None = None, + ) -> None: + """Bring mock in a well defined state.""" + super().__init__(ip, zeroconf_instance) + self.device.async_uptime = AsyncMock(side_effect=DevicePasswordProtected) diff --git a/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr b/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr index 9df6b168f9f..950aff87752 100644 --- a/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr +++ b/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr @@ -3,7 +3,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'band': '5 GHz', - 'icon': 'mdi:lan-connect', 'mac': 'AA:BB:CC:DD:EE:FF', 'source_type': , 'wifi': 'Main', diff --git a/tests/components/devolo_home_network/test_config_flow.py b/tests/components/devolo_home_network/test_config_flow.py index 92163b5cb95..923b7298893 100644 --- a/tests/components/devolo_home_network/test_config_flow.py +++ b/tests/components/devolo_home_network/test_config_flow.py @@ -5,11 +5,10 @@ from __future__ import annotations from typing import Any from unittest.mock import patch -from devolo_plc_api.exceptions.device import DeviceNotFound +from devolo_plc_api.exceptions.device import DeviceNotFound, DevicePasswordProtected import pytest from homeassistant import config_entries -from homeassistant.components.devolo_home_network import config_flow from homeassistant.components.devolo_home_network.const import ( DOMAIN, SERIAL_NUMBER, @@ -27,7 +26,7 @@ from .const import ( IP, IP_ALT, ) -from .mock import MockDevice +from .mock import MockDevice, MockDeviceWrongPassword async def test_form(hass: HomeAssistant, info: dict[str, Any]) -> None: @@ -44,15 +43,13 @@ async def test_form(hass: HomeAssistant, info: dict[str, Any]) -> None: ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_IP_ADDRESS: IP, - }, + {CONF_IP_ADDRESS: IP, CONF_PASSWORD: ""}, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["result"].unique_id == info["serial_number"] - assert result2["title"] == info["title"] + assert result2["result"].unique_id == info[SERIAL_NUMBER] + assert result2["title"] == info[TITLE] assert result2["data"] == { CONF_IP_ADDRESS: IP, CONF_PASSWORD: "", @@ -62,7 +59,11 @@ async def test_form(hass: HomeAssistant, info: dict[str, Any]) -> None: @pytest.mark.parametrize( ("exception_type", "expected_error"), - [(DeviceNotFound(IP), "cannot_connect"), (Exception, "unknown")], + [ + (DeviceNotFound(IP), "cannot_connect"), + (DevicePasswordProtected, "invalid_auth"), + (Exception, "unknown"), + ], ) async def test_form_error(hass: HomeAssistant, exception_type, expected_error) -> None: """Test we handle errors.""" @@ -108,9 +109,15 @@ async def test_zeroconf(hass: HomeAssistant) -> None: == DISCOVERY_INFO.hostname.split(".", maxsplit=1)[0] ) - with patch( - "homeassistant.components.devolo_home_network.async_setup_entry", - return_value=True, + with ( + patch( + "homeassistant.components.devolo_home_network.async_setup_entry", + return_value=True, + ), + patch( + "homeassistant.components.devolo_home_network.config_flow.Device", + new=MockDevice, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -127,6 +134,69 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "1234567890" +async def test_zeroconf_wrong_auth(hass: HomeAssistant) -> None: + """Test that the zeroconf form asks for password if authorization fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] is FlowResultType.FORM + assert result["description_placeholders"] == {"host_name": "test"} + + context = next( + flow["context"] + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + + assert ( + context["title_placeholders"][CONF_NAME] + == DISCOVERY_INFO.hostname.split(".", maxsplit=1)[0] + ) + + with ( + patch( + "homeassistant.components.devolo_home_network.async_setup_entry", + return_value=True, + ), + patch( + "homeassistant.components.devolo_home_network.config_flow.Device", + new=MockDeviceWrongPassword, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {CONF_BASE: "invalid_auth"} + + with ( + patch( + "homeassistant.components.devolo_home_network.async_setup_entry", + return_value=True, + ), + patch( + "homeassistant.components.devolo_home_network.config_flow.Device", + new=MockDevice, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PASSWORD: "new-password", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + + async def test_abort_zeroconf_wrong_device(hass: HomeAssistant) -> None: """Test we abort zeroconf for wrong devices.""" result = await hass.config_entries.flow.async_init( @@ -179,31 +249,43 @@ async def test_form_reauth(hass: HomeAssistant) -> None: assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM - with patch( - "homeassistant.components.devolo_home_network.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + with ( + patch( + "homeassistant.components.devolo_home_network.async_setup_entry", + return_value=True, + ), + patch( + "homeassistant.components.devolo_home_network.config_flow.Device", + new=MockDeviceWrongPassword, + ), + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_PASSWORD: "test-password-new"}, + {CONF_PASSWORD: "test-wrong-password"}, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {CONF_BASE: "invalid_auth"} + + with ( + patch( + "homeassistant.components.devolo_home_network.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + patch( + "homeassistant.components.devolo_home_network.config_flow.Device", + new=MockDevice, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "test-right-password"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" assert len(mock_setup_entry.mock_calls) == 1 await hass.config_entries.async_unload(entry.entry_id) - - -@pytest.mark.usefixtures("mock_device") -@pytest.mark.usefixtures("mock_zeroconf") -async def test_validate_input(hass: HomeAssistant) -> None: - """Test input validation.""" - with patch( - "homeassistant.components.devolo_home_network.config_flow.Device", - new=MockDevice, - ): - info = await config_flow.validate_input(hass, {CONF_IP_ADDRESS: IP}) - assert SERIAL_NUMBER in info - assert TITLE in info diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index 1cce11c36f9..ac86eb54961 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DeviceUnavailable from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.device_tracker import DOMAIN as PLATFORM @@ -25,6 +26,7 @@ STATION = CONNECTED_STATIONS[0] SERIAL = DISCOVERY_INFO.properties["SN"] +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_device_tracker( hass: HomeAssistant, mock_device: MockDevice, @@ -42,14 +44,6 @@ async def test_device_tracker( freezer.tick(LONG_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - - # Enable entity - entity_registry.async_update_entity(state_key, disabled_by=None) - await hass.async_block_till_done() - freezer.tick(LONG_UPDATE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert hass.states.get(state_key) == snapshot # Emulate state change diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 56d2c21a5b2..c25aff7e9ad 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.components.switch import DOMAIN as SWITCH from homeassistant.components.update import DOMAIN as UPDATE from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import async_get_platforms @@ -24,8 +24,6 @@ from . import configure_integration from .const import IP from .mock import MockDevice -from tests.common import MockConfigEntry - @pytest.mark.parametrize( "device", ["mock_device", "mock_repeater_device", "mock_ipv6_device"] @@ -50,27 +48,6 @@ async def test_setup_entry( assert device_info == snapshot -@pytest.mark.usefixtures("mock_device") -async def test_setup_without_password(hass: HomeAssistant) -> None: - """Test setup entry without a device password set like used before HA Core 2022.06.""" - config = { - CONF_IP_ADDRESS: IP, - } - entry = MockConfigEntry(domain=DOMAIN, data=config) - entry.add_to_hass(hass) - # Patching async_forward_entry_setup* is not advisable, and should be refactored - # in the future. - with ( - patch( - "homeassistant.config_entries.ConfigEntries.async_forward_entry_setups", - return_value=True, - ), - patch("homeassistant.core.EventBus.async_listen_once"), - ): - assert await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.LOADED - - async def test_setup_device_not_found(hass: HomeAssistant) -> None: """Test setup entry.""" entry = configure_integration(hass) diff --git a/tests/components/devolo_home_network/test_switch.py b/tests/components/devolo_home_network/test_switch.py index b96697dc9cc..7a342780877 100644 --- a/tests/components/devolo_home_network/test_switch.py +++ b/tests/components/devolo_home_network/test_switch.py @@ -1,7 +1,7 @@ """Tests for the devolo Home Network switch.""" from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from devolo_plc_api.device_api import WifiGuestAccessGet from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable @@ -16,6 +16,7 @@ from homeassistant.components.devolo_home_network.const import ( from homeassistant.components.switch import DOMAIN as PLATFORM from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -106,18 +107,15 @@ async def test_update_enable_guest_wifi( mock_device.device.async_get_wifi_guest_access.return_value = WifiGuestAccessGet( enabled=False ) - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_wifi_guest_access", - new=AsyncMock(), - ) as turn_off: - await hass.services.async_call( - PLATFORM, SERVICE_TURN_OFF, {"entity_id": state_key}, blocking=True - ) + await hass.services.async_call( + PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True + ) - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_OFF - turn_off.assert_called_once_with(False) + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_OFF + mock_device.device.async_set_wifi_guest_access.assert_called_once_with(False) + mock_device.device.async_set_wifi_guest_access.reset_mock() freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) async_fire_time_changed(hass) @@ -127,18 +125,15 @@ async def test_update_enable_guest_wifi( mock_device.device.async_get_wifi_guest_access.return_value = WifiGuestAccessGet( enabled=True ) - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_wifi_guest_access", - new=AsyncMock(), - ) as turn_on: - await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True - ) + await hass.services.async_call( + PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True + ) - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_ON - turn_on.assert_called_once_with(True) + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_ON + mock_device.device.async_set_wifi_guest_access.assert_called_once_with(True) + mock_device.device.async_set_wifi_guest_access.reset_mock() freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) async_fire_time_changed(hass) @@ -146,17 +141,17 @@ async def test_update_enable_guest_wifi( # Device unavailable mock_device.device.async_get_wifi_guest_access.side_effect = DeviceUnavailable() - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_wifi_guest_access", - side_effect=DeviceUnavailable, + mock_device.device.async_set_wifi_guest_access.side_effect = DeviceUnavailable() + + with pytest.raises( + HomeAssistantError, match=f"Device {entry.title} did not respond" ): await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True + PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True ) - - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNAVAILABLE + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE await hass.config_entries.async_unload(entry.entry_id) @@ -191,18 +186,15 @@ async def test_update_enable_leds( # Switch off mock_device.device.async_get_led_setting.return_value = False - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_led_setting", - new=AsyncMock(), - ) as turn_off: - await hass.services.async_call( - PLATFORM, SERVICE_TURN_OFF, {"entity_id": state_key}, blocking=True - ) + await hass.services.async_call( + PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True + ) - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_OFF - turn_off.assert_called_once_with(False) + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_OFF + mock_device.device.async_set_led_setting.assert_called_once_with(False) + mock_device.device.async_set_led_setting.reset_mock() freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) async_fire_time_changed(hass) @@ -210,18 +202,15 @@ async def test_update_enable_leds( # Switch on mock_device.device.async_get_led_setting.return_value = True - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_led_setting", - new=AsyncMock(), - ) as turn_on: - await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True - ) + await hass.services.async_call( + PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True + ) - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_ON - turn_on.assert_called_once_with(True) + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_ON + mock_device.device.async_set_led_setting.assert_called_once_with(True) + mock_device.device.async_set_led_setting.reset_mock() freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) async_fire_time_changed(hass) @@ -229,17 +218,17 @@ async def test_update_enable_leds( # Device unavailable mock_device.device.async_get_led_setting.side_effect = DeviceUnavailable() - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_led_setting", - side_effect=DeviceUnavailable, + mock_device.device.async_set_led_setting.side_effect = DeviceUnavailable() + + with pytest.raises( + HomeAssistantError, match=f"Device {entry.title} did not respond" ): await hass.services.async_call( - PLATFORM, SERVICE_TURN_OFF, {"entity_id": state_key}, blocking=True + PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True ) - - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNAVAILABLE + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE await hass.config_entries.async_unload(entry.entry_id) @@ -308,7 +297,7 @@ async def test_auth_failed( with pytest.raises(HomeAssistantError): await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True + PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True ) await hass.async_block_till_done() diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 223dc83f83a..f036902faed 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -1,5 +1,7 @@ """Test the DHCP discovery integration.""" +from __future__ import annotations + from collections.abc import Awaitable, Callable import datetime import threading @@ -24,6 +26,7 @@ from homeassistant.components.device_tracker import ( SourceType, ) from homeassistant.components.dhcp.const import DOMAIN +from homeassistant.components.dhcp.models import DHCPData from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, @@ -147,12 +150,12 @@ async def _async_get_handle_dhcp_packet( integration_matchers: dhcp.DhcpMatchers, address_data: dict | None = None, ) -> Callable[[Any], Awaitable[None]]: + """Make a handler for a dhcp packet.""" if address_data is None: address_data = {} dhcp_watcher = dhcp.DHCPWatcher( hass, - address_data, - integration_matchers, + DHCPData(integration_matchers, set(), address_data), ) with patch("aiodhcpwatcher.async_start"): await dhcp_watcher.async_start() @@ -666,6 +669,45 @@ async def test_setup_fails_with_broken_libpcap( ) +def _make_device_tracker_watcher( + hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher] +) -> dhcp.DeviceTrackerWatcher: + return dhcp.DeviceTrackerWatcher( + hass, + DHCPData( + dhcp.async_index_integration_matchers(matchers), + set(), + {}, + ), + ) + + +def _make_device_tracker_registered_watcher( + hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher] +) -> dhcp.DeviceTrackerRegisteredWatcher: + return dhcp.DeviceTrackerRegisteredWatcher( + hass, + DHCPData( + dhcp.async_index_integration_matchers(matchers), + set(), + {}, + ), + ) + + +def _make_network_watcher( + hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher] +) -> dhcp.NetworkWatcher: + return dhcp.NetworkWatcher( + hass, + DHCPData( + dhcp.async_index_integration_matchers(matchers), + set(), + {}, + ), + ) + + async def test_device_tracker_hostname_and_macaddress_exists_before_start( hass: HomeAssistant, ) -> None: @@ -682,18 +724,15 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start( ) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -716,18 +755,15 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start( async def test_device_tracker_registered(hass: HomeAssistant) -> None: """Test matching based on hostname and macaddress when registered.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerRegisteredWatcher( + device_tracker_watcher = _make_device_tracker_registered_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -756,18 +792,15 @@ async def test_device_tracker_registered(hass: HomeAssistant) -> None: async def test_device_tracker_registered_hostname_none(hass: HomeAssistant) -> None: """Test handle None hostname.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerRegisteredWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -789,18 +822,15 @@ async def test_device_tracker_hostname_and_macaddress_after_start( """Test matching based on hostname and macaddress after start.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -837,18 +867,15 @@ async def test_device_tracker_hostname_and_macaddress_after_start_not_home( """Test matching based on hostname and macaddress after start but not home.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -875,9 +902,8 @@ async def test_device_tracker_hostname_and_macaddress_after_start_not_router( """Test matching based on hostname and macaddress after start but not router.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], ) device_tracker_watcher.async_start() @@ -905,9 +931,8 @@ async def test_device_tracker_hostname_and_macaddress_after_start_hostname_missi """Test matching based on hostname and macaddress after start but missing hostname.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], ) device_tracker_watcher.async_start() @@ -934,9 +959,8 @@ async def test_device_tracker_invalid_ip_address( """Test an invalid ip address.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], ) device_tracker_watcher.async_start() @@ -974,18 +998,15 @@ async def test_device_tracker_ignore_self_assigned_ips_before_start( ) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -1010,18 +1031,15 @@ async def test_aiodiscover_finds_new_hosts(hass: HomeAssistant) -> None: ], ), ): - device_tracker_watcher = dhcp.NetworkWatcher( + device_tracker_watcher = _make_network_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -1073,18 +1091,15 @@ async def test_aiodiscover_does_not_call_again_on_shorter_hostname( ], ), ): - device_tracker_watcher = dhcp.NetworkWatcher( + device_tracker_watcher = _make_network_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "irobot-*", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "irobot-*", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -1123,19 +1138,17 @@ async def test_aiodiscover_finds_new_hosts_after_interval(hass: HomeAssistant) - return_value=[], ), ): - device_tracker_watcher = dhcp.NetworkWatcher( + device_tracker_watcher = _make_network_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) + device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -1235,7 +1248,7 @@ async def test_dhcp_rediscover( hass, integration_matchers, address_data ) rediscovery_watcher = dhcp.RediscoveryWatcher( - hass, address_data, integration_matchers + hass, DHCPData(integration_matchers, set(), address_data) ) rediscovery_watcher.async_start() with patch.object(hass.config_entries.flow, "async_init") as mock_init: @@ -1329,7 +1342,7 @@ async def test_dhcp_rediscover_no_match( hass, integration_matchers, address_data ) rediscovery_watcher = dhcp.RediscoveryWatcher( - hass, address_data, integration_matchers + hass, DHCPData(integration_matchers, set(), address_data) ) rediscovery_watcher.async_start() with patch.object(hass.config_entries.flow, "async_init") as mock_init: diff --git a/tests/components/dhcp/test_websocket_api.py b/tests/components/dhcp/test_websocket_api.py new file mode 100644 index 00000000000..eb008c49ab1 --- /dev/null +++ b/tests/components/dhcp/test_websocket_api.py @@ -0,0 +1,75 @@ +"""The tests for the dhcp WebSocket API.""" + +import asyncio +from collections.abc import Callable +from unittest.mock import patch + +import aiodhcpwatcher + +from homeassistant.components.dhcp import DOMAIN +from homeassistant.core import EVENT_HOMEASSISTANT_STARTED, HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import WebSocketGenerator + + +async def test_subscribe_discovery( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test dhcp subscribe_discovery.""" + saved_callback: Callable[[aiodhcpwatcher.DHCPRequest], None] | None = None + + async def mock_start( + callback: Callable[[aiodhcpwatcher.DHCPRequest], None], + ) -> None: + """Mock start.""" + nonlocal saved_callback + saved_callback = callback + + with ( + patch("homeassistant.components.dhcp.aiodhcpwatcher.async_start", mock_start), + patch("homeassistant.components.dhcp.DiscoverHosts"), + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + saved_callback(aiodhcpwatcher.DHCPRequest("4.3.2.2", "happy", "44:44:33:11:23:12")) + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "dhcp/subscribe_discovery", + } + ) + 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": [ + { + "hostname": "happy", + "ip_address": "4.3.2.2", + "mac_address": "44:44:33:11:23:12", + } + ] + } + + saved_callback(aiodhcpwatcher.DHCPRequest("4.3.2.1", "sad", "44:44:33:11:23:13")) + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "hostname": "sad", + "ip_address": "4.3.2.1", + "mac_address": "44:44:33:11:23:13", + } + ] + } diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index a92f7807912..f1ac2d6b1c2 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -1058,6 +1058,7 @@ async def test_browse_media( ), "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, } @@ -1070,6 +1071,7 @@ async def test_browse_media( "media_content_id": "media-source://media_source/local/test.mp3", "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, } @@ -1153,6 +1155,7 @@ async def test_browse_media_unfiltered( ), "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, } @@ -1163,6 +1166,7 @@ async def test_browse_media_unfiltered( "media_content_id": "media-source://media_source/local/test.mp3", "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, } diff --git a/tests/components/dnsip/test_config_flow.py b/tests/components/dnsip/test_config_flow.py index 9d92cb3554c..1a565345275 100644 --- a/tests/components/dnsip/test_config_flow.py +++ b/tests/components/dnsip/test_config_flow.py @@ -224,16 +224,20 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_RESOLVER: "8.8.8.8", - CONF_RESOLVER_IPV6: "2001:4860:4860::8888", - CONF_PORT: 53, - CONF_PORT_IPV6: 53, - }, - ) - await hass.async_block_till_done() + with patch( + "homeassistant.components.dnsip.config_flow.aiodns.DNSResolver", + return_value=RetrieveDNS(), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_RESOLVER: "8.8.8.8", + CONF_RESOLVER_IPV6: "2001:4860:4860::8888", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, + }, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { diff --git a/tests/components/duckdns/test_init.py b/tests/components/duckdns/test_init.py index 313cc91aa18..7806d57e934 100644 --- a/tests/components/duckdns/test_init.py +++ b/tests/components/duckdns/test_init.py @@ -31,16 +31,16 @@ async def async_set_txt(hass: HomeAssistant, txt: str | None) -> None: @pytest.fixture -def setup_duckdns(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def setup_duckdns( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Fixture that sets up DuckDNS.""" aioclient_mock.get( duckdns.UPDATE_URL, params={"domains": DOMAIN, "token": TOKEN}, text="OK" ) - hass.loop.run_until_complete( - async_setup_component( - hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}} - ) + await async_setup_component( + hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}} ) diff --git a/tests/components/ecovacs/test_binary_sensor.py b/tests/components/ecovacs/test_binary_sensor.py index b57f67e948e..16e2d3fefc5 100644 --- a/tests/components/ecovacs/test_binary_sensor.py +++ b/tests/components/ecovacs/test_binary_sensor.py @@ -1,6 +1,6 @@ """Tests for Ecovacs binary sensors.""" -from deebot_client.events import WaterAmount, WaterInfoEvent +from deebot_client.events.water_info import MopAttachedEvent import pytest from syrupy import SnapshotAssertion @@ -43,16 +43,12 @@ async def test_mop_attached( assert device_entry.identifiers == {(DOMAIN, device.device_info["did"])} event_bus = device.events - await notify_and_wait( - hass, event_bus, WaterInfoEvent(WaterAmount.HIGH, mop_attached=True) - ) + await notify_and_wait(hass, event_bus, MopAttachedEvent(True)) assert (state := hass.states.get(state.entity_id)) assert state == snapshot(name=f"{entity_id}-state") - await notify_and_wait( - hass, event_bus, WaterInfoEvent(WaterAmount.HIGH, mop_attached=False) - ) + await notify_and_wait(hass, event_bus, MopAttachedEvent(False)) assert (state := hass.states.get(state.entity_id)) assert state.state == STATE_OFF diff --git a/tests/components/ecovacs/test_select.py b/tests/components/ecovacs/test_select.py index 02a6b5ebfa4..1e03bb18e28 100644 --- a/tests/components/ecovacs/test_select.py +++ b/tests/components/ecovacs/test_select.py @@ -3,7 +3,7 @@ from deebot_client.command import Command from deebot_client.commands.json import SetWaterInfo from deebot_client.event_bus import EventBus -from deebot_client.events import WaterAmount, WaterInfoEvent +from deebot_client.events.water_info import WaterAmount, WaterAmountEvent import pytest from syrupy import SnapshotAssertion @@ -33,7 +33,7 @@ def platforms() -> Platform | list[Platform]: async def notify_events(hass: HomeAssistant, event_bus: EventBus): """Notify events.""" - event_bus.notify(WaterInfoEvent(WaterAmount.ULTRAHIGH)) + event_bus.notify(WaterAmountEvent(WaterAmount.ULTRAHIGH)) await block_till_done(hass, event_bus) diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py index ae1bc74df90..654028c7c11 100644 --- a/tests/components/eheimdigital/conftest.py +++ b/tests/components/eheimdigital/conftest.py @@ -1,12 +1,21 @@ """Configurations for the EHEIM Digital tests.""" from collections.abc import Generator +from datetime import time, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.classic_vario import EheimDigitalClassicVario from eheimdigital.heater import EheimDigitalHeater from eheimdigital.hub import EheimDigitalHub -from eheimdigital.types import EheimDeviceType, HeaterMode, HeaterUnit, LightMode +from eheimdigital.types import ( + EheimDeviceType, + FilterErrorCode, + FilterMode, + HeaterMode, + HeaterUnit, + LightMode, +) import pytest from homeassistant.components.eheimdigital.const import DOMAIN @@ -53,15 +62,45 @@ def heater_mock(): heater_mock.temperature_unit = HeaterUnit.CELSIUS heater_mock.current_temperature = 24.2 heater_mock.target_temperature = 25.5 + heater_mock.temperature_offset = 0.1 + heater_mock.night_temperature_offset = -0.2 heater_mock.is_heating = True heater_mock.is_active = True heater_mock.operation_mode = HeaterMode.MANUAL + heater_mock.day_start_time = time(8, 0, tzinfo=timezone(timedelta(hours=1))) + heater_mock.night_start_time = time(20, 0, tzinfo=timezone(timedelta(hours=1))) return heater_mock +@pytest.fixture +def classic_vario_mock(): + """Mock a classicVARIO device.""" + classic_vario_mock = MagicMock(spec=EheimDigitalClassicVario) + classic_vario_mock.mac_address = "00:00:00:00:00:03" + classic_vario_mock.device_type = EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + classic_vario_mock.name = "Mock classicVARIO" + classic_vario_mock.aquarium_name = "Mock Aquarium" + classic_vario_mock.sw_version = "1.0.0_1.0.0" + classic_vario_mock.current_speed = 75 + classic_vario_mock.manual_speed = 75 + classic_vario_mock.day_speed = 80 + classic_vario_mock.day_start_time = time(8, 0, tzinfo=timezone(timedelta(hours=1))) + classic_vario_mock.night_start_time = time( + 20, 0, tzinfo=timezone(timedelta(hours=1)) + ) + classic_vario_mock.night_speed = 20 + classic_vario_mock.is_active = True + classic_vario_mock.filter_mode = FilterMode.MANUAL + classic_vario_mock.error_code = FilterErrorCode.NO_ERROR + classic_vario_mock.service_hours = 360 + return classic_vario_mock + + @pytest.fixture def eheimdigital_hub_mock( - classic_led_ctrl_mock: MagicMock, heater_mock: MagicMock + classic_led_ctrl_mock: MagicMock, + heater_mock: MagicMock, + classic_vario_mock: MagicMock, ) -> Generator[AsyncMock]: """Mock eheimdigital hub.""" with ( @@ -77,6 +116,7 @@ def eheimdigital_hub_mock( eheimdigital_hub_mock.return_value.devices = { "00:00:00:00:00:01": classic_led_ctrl_mock, "00:00:00:00:00:02": heater_mock, + "00:00:00:00:00:03": classic_vario_mock, } eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock yield eheimdigital_hub_mock diff --git a/tests/components/eheimdigital/snapshots/test_number.ambr b/tests/components/eheimdigital/snapshots/test_number.ambr new file mode 100644 index 00000000000..d647b16bf49 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_number.ambr @@ -0,0 +1,286 @@ +# serializer version: 1 +# name: test_setup[number.mock_classicvario_day_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_classicvario_day_speed', + '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': 'Day speed', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'day_speed', + 'unique_id': '00:00:00:00:00:03_day_speed', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[number.mock_classicvario_day_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Day speed', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_classicvario_day_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[number.mock_classicvario_manual_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_classicvario_manual_speed', + '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': 'Manual speed', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'manual_speed', + 'unique_id': '00:00:00:00:00:03_manual_speed', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[number.mock_classicvario_manual_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Manual speed', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_classicvario_manual_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[number.mock_classicvario_night_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_classicvario_night_speed', + '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': 'Night speed', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'night_speed', + 'unique_id': '00:00:00:00:00:03_night_speed', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[number.mock_classicvario_night_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Night speed', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_classicvario_night_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[number.mock_heater_night_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': -5, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_heater_night_temperature_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Night temperature offset', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'night_temperature_offset', + 'unique_id': '00:00:00:00:00:02_night_temperature_offset', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[number.mock_heater_night_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Heater Night temperature offset', + 'max': 5, + 'min': -5, + 'mode': , + 'step': 0.5, + }), + 'context': , + 'entity_id': 'number.mock_heater_night_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[number.mock_heater_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3, + 'min': -3, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_heater_temperature_offset', + '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 offset', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': '00:00:00:00:00:02_temperature_offset', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[number.mock_heater_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Heater Temperature offset', + 'max': 3, + 'min': -3, + 'mode': , + 'step': 0.1, + }), + 'context': , + 'entity_id': 'number.mock_heater_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/eheimdigital/snapshots/test_sensor.ambr b/tests/components/eheimdigital/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..c5a3d700331 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_sensor.ambr @@ -0,0 +1,160 @@ +# serializer version: 1 +# name: test_setup_classic_vario[sensor.mock_classicvario_current_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_classicvario_current_speed', + '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 speed', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_speed', + 'unique_id': '00:00:00:00:00:03_current_speed', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_current_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Current speed', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_classicvario_current_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_error_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'rotor_stuck', + 'air_in_filter', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_classicvario_error_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error code', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error_code', + 'unique_id': '00:00:00:00:00:03_error_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_error_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock classicVARIO Error code', + 'options': list([ + 'no_error', + 'rotor_stuck', + 'air_in_filter', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_classicvario_error_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_remaining_hours_until_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_classicvario_remaining_hours_until_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining hours until service', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'service_hours', + 'unique_id': '00:00:00:00:00:03_service_hours', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_remaining_hours_until_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Mock classicVARIO Remaining hours until service', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_classicvario_remaining_hours_until_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/eheimdigital/snapshots/test_switch.ambr b/tests/components/eheimdigital/snapshots/test_switch.ambr new file mode 100644 index 00000000000..73d229cb4ba --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_switch.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_setup_classic_vario[switch.mock_classicvario-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_classicvario', + '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': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_active', + 'unique_id': '00:00:00:00:00:03', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_vario[switch.mock_classicvario-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO', + }), + 'context': , + 'entity_id': 'switch.mock_classicvario', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/eheimdigital/snapshots/test_time.ambr b/tests/components/eheimdigital/snapshots/test_time.ambr new file mode 100644 index 00000000000..bdd4bdaddb7 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_time.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_setup[time.mock_classicvario_day_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.mock_classicvario_day_start_time', + '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': 'Day start time', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'day_start_time', + 'unique_id': '00:00:00:00:00:03_day_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[time.mock_classicvario_day_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Day start time', + }), + 'context': , + 'entity_id': 'time.mock_classicvario_day_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[time.mock_classicvario_night_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.mock_classicvario_night_start_time', + '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': 'Night start time', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'night_start_time', + 'unique_id': '00:00:00:00:00:03_night_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[time.mock_classicvario_night_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Night start time', + }), + 'context': , + 'entity_id': 'time.mock_classicvario_night_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[time.mock_heater_day_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.mock_heater_day_start_time', + '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': 'Day start time', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'day_start_time', + 'unique_id': '00:00:00:00:00:02_day_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[time.mock_heater_day_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Heater Day start time', + }), + 'context': , + 'entity_id': 'time.mock_heater_day_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[time.mock_heater_night_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.mock_heater_night_start_time', + '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': 'Night start time', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'night_start_time', + 'unique_id': '00:00:00:00:00:02_night_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[time.mock_heater_night_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Heater Night start time', + }), + 'context': , + 'entity_id': 'time.mock_heater_night_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/eheimdigital/test_number.py b/tests/components/eheimdigital/test_number.py new file mode 100644 index 00000000000..d84c14f95a5 --- /dev/null +++ b/tests/components/eheimdigital/test_number.py @@ -0,0 +1,189 @@ +"""Tests for the number module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +async def test_setup( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test number platform setup.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.NUMBER]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + for device in eheimdigital_hub_mock.return_value.devices: + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device, eheimdigital_hub_mock.return_value.devices[device].device_type + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "heater_mock", + [ + ( + "number.mock_heater_temperature_offset", + 0.4, + "set_temperature_offset", + (0.4,), + ), + ( + "number.mock_heater_night_temperature_offset", + 0.4, + "set_night_temperature_offset", + (0.4,), + ), + ], + ), + ( + "classic_vario_mock", + [ + ( + "number.mock_classicvario_manual_speed", + 72.1, + "set_manual_speed", + (int(72.1),), + ), + ( + "number.mock_classicvario_day_speed", + 72.1, + "set_day_speed", + (int(72.1),), + ), + ( + "number.mock_classicvario_night_speed", + 72.1, + "set_night_speed", + (int(72.1),), + ), + ], + ), + ], +) +async def test_set_value( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, float, str, tuple[float]]], + request: pytest.FixtureRequest, +) -> None: + """Test setting a value.""" + device: MagicMock = request.getfixturevalue(device_name) + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device.mac_address, device.device_type + ) + + await hass.async_block_till_done() + + for item in entity_list: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: item[0], ATTR_VALUE: item[1]}, + blocking=True, + ) + calls = [call for call in device.mock_calls if call[0] == item[2]] + assert len(calls) == 1 and calls[0][1] == item[3] + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "heater_mock", + [ + ( + "number.mock_heater_temperature_offset", + "temperature_offset", + -1.1, + ), + ( + "number.mock_heater_night_temperature_offset", + "night_temperature_offset", + 2.3, + ), + ], + ), + ( + "classic_vario_mock", + [ + ( + "number.mock_classicvario_manual_speed", + "manual_speed", + 34, + ), + ( + "number.mock_classicvario_day_speed", + "day_speed", + 79, + ), + ( + "number.mock_classicvario_night_speed", + "night_speed", + 12, + ), + ], + ), + ], +) +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, str, float]], + request: pytest.FixtureRequest, +) -> None: + """Test state updates.""" + device: MagicMock = request.getfixturevalue(device_name) + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device.mac_address, device.device_type + ) + + await hass.async_block_till_done() + + for item in entity_list: + setattr(device, item[1], item[2]) + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + assert (state := hass.states.get(item[0])) + assert state.state == str(item[2]) diff --git a/tests/components/eheimdigital/test_sensor.py b/tests/components/eheimdigital/test_sensor.py new file mode 100644 index 00000000000..ece4d3eb241 --- /dev/null +++ b/tests/components/eheimdigital/test_sensor.py @@ -0,0 +1,77 @@ +"""Tests for the sensor module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from eheimdigital.types import EheimDeviceType, FilterErrorCode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("classic_vario_mock") +async def test_setup_classic_vario( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor platform setup for the filter.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.SENSOR]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_vario_mock: MagicMock, +) -> None: + """Test the sensor state update.""" + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + ) + await hass.async_block_till_done() + + classic_vario_mock.current_speed = 10 + classic_vario_mock.error_code = FilterErrorCode.ROTOR_STUCK + classic_vario_mock.service_hours = 100 + + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + + assert (state := hass.states.get("sensor.mock_classicvario_current_speed")) + assert state.state == "10" + + assert (state := hass.states.get("sensor.mock_classicvario_error_code")) + assert state.state == "rotor_stuck" + + assert ( + state := hass.states.get( + "sensor.mock_classicvario_remaining_hours_until_service" + ) + ) + assert state.state == str(round(100 / 24, 1)) diff --git a/tests/components/eheimdigital/test_switch.py b/tests/components/eheimdigital/test_switch.py new file mode 100644 index 00000000000..440e4776b37 --- /dev/null +++ b/tests/components/eheimdigital/test_switch.py @@ -0,0 +1,105 @@ +"""Tests for the switch module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from eheimdigital.types import EheimDeviceType +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("classic_vario_mock") +async def test_setup_classic_vario( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test switch platform setup for the filter.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.SWITCH]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("service", "active"), [(SERVICE_TURN_OFF, False), (SERVICE_TURN_ON, True)] +) +async def test_turn_on_off( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_vario_mock: MagicMock, + service: str, + active: bool, +) -> None: + """Test turning on/off the switch.""" + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + ) + await hass.async_block_till_done() + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: "switch.mock_classicvario"}, + blocking=True, + ) + + classic_vario_mock.set_active.assert_awaited_once_with(active=active) + + +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_vario_mock: MagicMock, +) -> None: + """Test the switch state update.""" + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + ) + await hass.async_block_till_done() + + assert (state := hass.states.get("switch.mock_classicvario")) + assert state.state == STATE_ON + + classic_vario_mock.is_active = False + + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + + assert (state := hass.states.get("switch.mock_classicvario")) + assert state.state == STATE_OFF diff --git a/tests/components/eheimdigital/test_time.py b/tests/components/eheimdigital/test_time.py new file mode 100644 index 00000000000..acb96ae4023 --- /dev/null +++ b/tests/components/eheimdigital/test_time.py @@ -0,0 +1,179 @@ +"""Tests for the time module.""" + +from datetime import time, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.time import ( + ATTR_TIME, + DOMAIN as TIME_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +async def test_setup( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test number platform setup.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.TIME]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + for device in eheimdigital_hub_mock.return_value.devices: + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device, eheimdigital_hub_mock.return_value.devices[device].device_type + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "heater_mock", + [ + ( + "time.mock_heater_day_start_time", + time(9, 0, tzinfo=timezone(timedelta(hours=1))), + "set_day_start_time", + (time(9, 0, tzinfo=timezone(timedelta(hours=1))),), + ), + ( + "time.mock_heater_night_start_time", + time(19, 0, tzinfo=timezone(timedelta(hours=1))), + "set_night_start_time", + (time(19, 0, tzinfo=timezone(timedelta(hours=1))),), + ), + ], + ), + ( + "classic_vario_mock", + [ + ( + "time.mock_classicvario_day_start_time", + time(9, 0, tzinfo=timezone(timedelta(hours=1))), + "set_day_start_time", + (time(9, 0, tzinfo=timezone(timedelta(hours=1))),), + ), + ( + "time.mock_classicvario_night_start_time", + time(19, 0, tzinfo=timezone(timedelta(hours=1))), + "set_night_start_time", + (time(19, 0, tzinfo=timezone(timedelta(hours=1))),), + ), + ], + ), + ], +) +async def test_set_value( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, time, str, tuple[time]]], + request: pytest.FixtureRequest, +) -> None: + """Test setting a value.""" + device: MagicMock = request.getfixturevalue(device_name) + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device.mac_address, device.device_type + ) + + await hass.async_block_till_done() + + for item in entity_list: + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: item[0], ATTR_TIME: item[1]}, + blocking=True, + ) + calls = [call for call in device.mock_calls if call[0] == item[2]] + assert len(calls) == 1 and calls[0][1] == item[3] + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "heater_mock", + [ + ( + "time.mock_heater_day_start_time", + "day_start_time", + time(9, 0, tzinfo=timezone(timedelta(hours=3))), + ), + ( + "time.mock_heater_night_start_time", + "night_start_time", + time(19, 0, tzinfo=timezone(timedelta(hours=3))), + ), + ], + ), + ( + "classic_vario_mock", + [ + ( + "time.mock_classicvario_day_start_time", + "day_start_time", + time(9, 0, tzinfo=timezone(timedelta(hours=1))), + ), + ( + "time.mock_classicvario_night_start_time", + "night_start_time", + time(22, 0, tzinfo=timezone(timedelta(hours=1))), + ), + ], + ), + ], +) +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, str, time]], + request: pytest.FixtureRequest, +) -> None: + """Test state updates.""" + device: MagicMock = request.getfixturevalue(device_name) + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device.mac_address, device.device_type + ) + + await hass.async_block_till_done() + + for item in entity_list: + setattr(device, item[1], item[2]) + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + assert (state := hass.states.get(item[0])) + assert state.state == item[2].isoformat() diff --git a/tests/components/emoncms/test_config_flow.py b/tests/components/emoncms/test_config_flow.py index 1914f23fb0b..fa8ae7ce068 100644 --- a/tests/components/emoncms/test_config_flow.py +++ b/tests/components/emoncms/test_config_flow.py @@ -3,64 +3,16 @@ from unittest.mock import AsyncMock from homeassistant.components.emoncms.const import CONF_ONLY_INCLUDE_FEEDID, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import setup_integration -from .conftest import EMONCMS_FAILURE, FLOW_RESULT_SINGLE_FEED, SENSOR_NAME, YAML +from .conftest import EMONCMS_FAILURE, SENSOR_NAME from tests.common import MockConfigEntry - -async def test_flow_import_include_feeds( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - emoncms_client: AsyncMock, -) -> None: - """YAML import with included feed - success test.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=YAML, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == SENSOR_NAME - assert result["data"] == FLOW_RESULT_SINGLE_FEED - - -async def test_flow_import_failure( - hass: HomeAssistant, - emoncms_client: AsyncMock, -) -> None: - """YAML import - failure test.""" - emoncms_client.async_request.return_value = EMONCMS_FAILURE - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=YAML, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "api_error" - - -async def test_flow_import_already_configured( - hass: HomeAssistant, - config_entry: MockConfigEntry, - emoncms_client: AsyncMock, -) -> None: - """Test we abort import data set when entry is already configured.""" - config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=YAML, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - USER_INPUT = { CONF_URL: "http://1.1.1.1", CONF_API_KEY: "my_api_key", diff --git a/tests/components/emoncms/test_sensor.py b/tests/components/emoncms/test_sensor.py index a7bc8059287..2d976f483b3 100644 --- a/tests/components/emoncms/test_sensor.py +++ b/tests/components/emoncms/test_sensor.py @@ -7,12 +7,9 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.emoncms.const import DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import setup_integration from .conftest import EMONCMS_FAILURE, get_feed @@ -20,56 +17,6 @@ from .conftest import EMONCMS_FAILURE, get_feed from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -async def test_deprecated_yaml( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - emoncms_yaml_config: ConfigType, - emoncms_client: AsyncMock, -) -> None: - """Test an issue is created when we import from yaml config.""" - - await async_setup_component(hass, SENSOR_DOMAIN, emoncms_yaml_config) - await hass.async_block_till_done() - - assert issue_registry.async_get_issue( - domain=HOMEASSISTANT_DOMAIN, issue_id=f"deprecated_yaml_{DOMAIN}" - ) - - -async def test_yaml_with_template( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - emoncms_yaml_config_with_template: ConfigType, - emoncms_client: AsyncMock, -) -> None: - """Test an issue is created when we import a yaml config with a value_template parameter.""" - - await async_setup_component(hass, SENSOR_DOMAIN, emoncms_yaml_config_with_template) - await hass.async_block_till_done() - - assert issue_registry.async_get_issue( - domain=DOMAIN, issue_id=f"remove_value_template_{DOMAIN}" - ) - - -async def test_yaml_no_include_only_feed_id( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - emoncms_yaml_config_no_include_only_feed_id: ConfigType, - emoncms_client: AsyncMock, -) -> None: - """Test an issue is created when we import a yaml config without a include_only_feed_id parameter.""" - - await async_setup_component( - hass, SENSOR_DOMAIN, emoncms_yaml_config_no_include_only_feed_id - ) - await hass.async_block_till_done() - - assert issue_registry.async_get_issue( - domain=DOMAIN, issue_id=f"missing_include_only_feed_id_{DOMAIN}" - ) - - async def test_no_feed_selected( hass: HomeAssistant, config_no_feed: MockConfigEntry, diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 97dcc782096..0f8ffcbee9f 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -103,6 +103,7 @@ ENTITY_IDS_BY_NUMBER = { "26": "light.living_room_rgbww_lights", "27": "media_player.group", "28": "media_player.browse", + "29": "media_player.search", } ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()} diff --git a/tests/components/emulated_roku/test_binding.py b/tests/components/emulated_roku/test_binding.py index 5bde72d2e4d..ec3f064dfe0 100644 --- a/tests/components/emulated_roku/test_binding.py +++ b/tests/components/emulated_roku/test_binding.py @@ -1,6 +1,7 @@ """Tests for emulated_roku library bindings.""" from unittest.mock import AsyncMock, Mock, patch +from uuid import uuid4 from homeassistant.components.emulated_roku.binding import ( ATTR_APP_ID, @@ -14,14 +15,15 @@ from homeassistant.components.emulated_roku.binding import ( ROKU_COMMAND_LAUNCH, EmulatedRoku, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant async def test_events_fired_properly(hass: HomeAssistant) -> None: """Test that events are fired correctly.""" - binding = EmulatedRoku( - hass, "Test Emulated Roku", "1.2.3.4", 8060, None, None, None - ) + random_name = uuid4().hex + # Note that this test is accessing the internal EmulatedRoku class + # and should be refactored in the future not to do so. + binding = EmulatedRoku(hass, "x", random_name, "1.2.3.4", 8060, None, None, None) events = [] roku_event_handler = None @@ -41,8 +43,9 @@ async def test_events_fired_properly(hass: HomeAssistant) -> None: return Mock(start=AsyncMock(), close=AsyncMock()) - def listener(event): - events.append(event) + def listener(event: Event) -> None: + if event.data[ATTR_SOURCE_NAME] == random_name: + events.append(event) with patch( "homeassistant.components.emulated_roku.binding.EmulatedRokuServer", instantiate @@ -53,10 +56,10 @@ async def test_events_fired_properly(hass: HomeAssistant) -> None: assert roku_event_handler is not None - roku_event_handler.on_keydown("Test Emulated Roku", "A") - roku_event_handler.on_keyup("Test Emulated Roku", "A") - roku_event_handler.on_keypress("Test Emulated Roku", "C") - roku_event_handler.launch("Test Emulated Roku", "1") + roku_event_handler.on_keydown(random_name, "A") + roku_event_handler.on_keyup(random_name, "A") + roku_event_handler.on_keypress(random_name, "C") + roku_event_handler.launch(random_name, "1") await hass.async_block_till_done() @@ -64,20 +67,20 @@ async def test_events_fired_properly(hass: HomeAssistant) -> None: assert events[0].event_type == EVENT_ROKU_COMMAND assert events[0].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYDOWN - assert events[0].data[ATTR_SOURCE_NAME] == "Test Emulated Roku" + assert events[0].data[ATTR_SOURCE_NAME] == random_name assert events[0].data[ATTR_KEY] == "A" assert events[1].event_type == EVENT_ROKU_COMMAND assert events[1].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYUP - assert events[1].data[ATTR_SOURCE_NAME] == "Test Emulated Roku" + assert events[1].data[ATTR_SOURCE_NAME] == random_name assert events[1].data[ATTR_KEY] == "A" assert events[2].event_type == EVENT_ROKU_COMMAND assert events[2].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYPRESS - assert events[2].data[ATTR_SOURCE_NAME] == "Test Emulated Roku" + assert events[2].data[ATTR_SOURCE_NAME] == random_name assert events[2].data[ATTR_KEY] == "C" assert events[3].event_type == EVENT_ROKU_COMMAND assert events[3].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_LAUNCH - assert events[3].data[ATTR_SOURCE_NAME] == "Test Emulated Roku" + assert events[3].data[ATTR_SOURCE_NAME] == random_name assert events[3].data[ATTR_APP_ID] == "1" diff --git a/tests/components/emulated_roku/test_init.py b/tests/components/emulated_roku/test_init.py index cf2a415f19c..473e0c662aa 100644 --- a/tests/components/emulated_roku/test_init.py +++ b/tests/components/emulated_roku/test_init.py @@ -86,16 +86,6 @@ async def test_setup_entry_successful(hass: HomeAssistant) -> None: assert await emulated_roku.async_setup_entry(hass, entry) is True assert len(instantiate.mock_calls) == 1 - assert hass.data[emulated_roku.DOMAIN] - - roku_instance = hass.data[emulated_roku.DOMAIN]["Emulated Roku Test"] - - assert roku_instance.roku_usn == "Emulated Roku Test" - assert roku_instance.host_ip == "1.2.3.5" - assert roku_instance.listen_port == 8060 - assert roku_instance.advertise_ip == "1.2.3.4" - assert roku_instance.advertise_port == 8071 - assert roku_instance.bind_multicast is False async def test_unload_entry(hass: HomeAssistant) -> None: @@ -113,10 +103,6 @@ async def test_unload_entry(hass: HomeAssistant) -> None: ): assert await emulated_roku.async_setup_entry(hass, entry) is True - assert emulated_roku.DOMAIN in hass.data - await hass.async_block_till_done() assert await emulated_roku.async_unload_entry(hass, entry) - - assert len(hass.data[emulated_roku.DOMAIN]) == 0 diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index e4b0e568a70..54f2a971fd4 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -1165,3 +1165,59 @@ async def test_fossil_energy_consumption_check_missing_hour( hour3.isoformat(), hour4.isoformat(), ] + + +@pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") +async def test_fossil_energy_consumption_missing_sum( + recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test fossil_energy_consumption statistics missing sum.""" + now = dt_util.utcnow() + later = dt_util.as_utc(dt_util.parse_datetime("2022-09-01 00:00:00")) + + await async_setup_component(hass, "history", {}) + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + + period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) + + external_energy_statistics_1 = ( + {"start": period1, "last_reset": None, "state": 0, "mean": 2}, + {"start": period2, "last_reset": None, "state": 1, "mean": 3}, + {"start": period3, "last_reset": None, "state": 2, "mean": 4}, + {"start": period4, "last_reset": None, "state": 3, "mean": 5}, + ) + external_energy_metadata_1 = { + "has_mean": True, + "has_sum": False, + "name": "Mean imported energy", + "source": "test", + "statistic_id": "test:mean_energy_import_tariff", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_1 + ) + await async_wait_recording_done(hass) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:mean_energy_import_tariff", + ], + "co2_statistic_id": "", + "period": "hour", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index b860d49aa6b..89a0e9b4610 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -20,6 +20,7 @@ from pyenphase import ( ) from pyenphase.const import SupportedFeatures from pyenphase.models.dry_contacts import EnvoyDryContactSettings, EnvoyDryContactStatus +from pyenphase.models.home import EnvoyInterfaceInformation from pyenphase.models.meters import EnvoyMeterData from pyenphase.models.tariff import EnvoyStorageSettings, EnvoyTariff import pytest @@ -145,6 +146,11 @@ def load_envoy_fixture(mock_envoy: AsyncMock, fixture_name: str) -> None: _load_json_2_encharge_enpower_data(mock_envoy.data, json_fixture) _load_json_2_raw_data(mock_envoy.data, json_fixture) + if item := json_fixture.get("interface_information"): + mock_envoy.interface_settings.return_value = EnvoyInterfaceInformation(**item) + else: + mock_envoy.interface_settings.return_value = None + def _load_json_2_production_data( mocked_data: EnvoyData, json_fixture: dict[str, Any] diff --git a/tests/components/enphase_envoy/fixtures/envoy.json b/tests/components/enphase_envoy/fixtures/envoy.json index 3431dba6766..c619d61a393 100644 --- a/tests/components/enphase_envoy/fixtures/envoy.json +++ b/tests/components/enphase_envoy/fixtures/envoy.json @@ -47,5 +47,13 @@ "raw": { "varies_by": "firmware_version" } + }, + "interface_information": { + "primary_interface": "eth0", + "interface_type": "ethernet", + "mac": "00:11:22:33:44:55", + "dhcp": true, + "software_build_epoch": 1719503966, + "timezone": "Europe/Amsterdam" } } diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 152cf803258..acbd7de6c0e 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -423,6 +423,8 @@ 'tariff': None, }), 'envoy_properties': dict({ + 'active interface': dict({ + }), 'active_phasecount': 0, 'ct_consumption_meter': None, 'ct_count': 0, @@ -870,6 +872,8 @@ 'tariff': None, }), 'envoy_properties': dict({ + 'active interface': dict({ + }), 'active_phasecount': 0, 'ct_consumption_meter': None, 'ct_count': 0, @@ -892,6 +896,8 @@ '/api/v1/production/inverters': 'Testing request replies.', '/api/v1/production/inverters_log': '{"headers":{"Hello":"World"},"code":200}', '/api/v1/production_log': '{"headers":{"Hello":"World"},"code":200}', + '/home,': 'Testing request replies.', + '/home,_log': '{"headers":{"Hello":"World"},"code":200}', '/info': 'Testing request replies.', '/info_log': '{"headers":{"Hello":"World"},"code":200}', '/ivp/ensemble/dry_contacts': 'Testing request replies.', @@ -1357,6 +1363,529 @@ 'tariff': None, }), 'envoy_properties': dict({ + 'active interface': dict({ + }), + 'active_phasecount': 0, + 'ct_consumption_meter': None, + 'ct_count': 0, + 'ct_production_meter': None, + 'ct_storage_meter': None, + 'envoy_firmware': '7.6.175', + 'envoy_model': 'Envoy', + 'part_number': '123456789', + 'phase_count': 1, + 'phase_mode': None, + 'supported_features': list([ + 'INVERTERS', + 'PRODUCTION', + ]), + }), + 'fixtures': dict({ + '/admin/lib/tariff_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/api/v1/production/inverters_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/api/v1/production_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/home,_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/info_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/dry_contacts_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/generator_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/inventory_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/power_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/secctrl_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/status_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/meters/readings_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/meters_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/sc/pvlimit_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/dry_contact_settings_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/gen_config_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/gen_schedule_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/pel_settings_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/production.json?details=1_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/production.json_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/production_log': dict({ + 'Error': "EnvoyError('Test')", + }), + }), + 'raw_data': dict({ + 'varies_by': 'firmware_version', + }), + }) +# --- +# name: test_entry_diagnostics_with_interface_information + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': '1.1.1.1', + 'name': '**REDACTED**', + 'password': '**REDACTED**', + 'token': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'enphase_envoy', + 'entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + 'envoy_entities_by_device': list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + '45a36e55aaddb2007c5f6602e0c38e72', + ]), + 'config_entries_subentries': dict({ + '45a36e55aaddb2007c5f6602e0c38e72': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'identifiers': list([ + list([ + 'enphase_envoy', + '1', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'Enphase', + 'model': 'Inverter', + 'model_id': None, + 'name': 'Inverter 1', + 'name_by_user': None, + 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + }), + 'entities': list([ + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'power', + 'original_icon': None, + 'original_name': None, + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1', + 'unit_of_measurement': 'W', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1', + 'state_class': 'measurement', + 'unit_of_measurement': 'W', + }), + 'entity_id': 'sensor.inverter_1', + 'state': '1', + }), + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_last_reported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'timestamp', + 'original_icon': None, + 'original_name': 'Last reported', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_reported', + 'unique_id': '1_last_reported', + 'unit_of_measurement': None, + }), + 'state': None, + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + '45a36e55aaddb2007c5f6602e0c38e72', + ]), + 'config_entries_subentries': dict({ + '45a36e55aaddb2007c5f6602e0c38e72': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + list([ + 'mac', + '00:11:22:33:44:55', + ]), + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '<>56789', + 'identifiers': list([ + list([ + 'enphase_envoy', + '<>', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'Enphase', + 'model': 'Envoy', + 'model_id': None, + 'name': 'Envoy <>', + 'name_by_user': None, + 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', + 'serial_number': '<>', + 'suggested_area': None, + 'sw_version': '7.6.175', + }), + 'entities': list([ + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_<>_current_power_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'kW', + }), + }), + 'original_device_class': 'power', + 'original_icon': None, + 'original_name': 'Current power production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_power_production', + 'unique_id': '<>_production', + 'unit_of_measurement': 'kW', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'Envoy <> Current power production', + 'state_class': 'measurement', + 'unit_of_measurement': 'kW', + }), + 'entity_id': 'sensor.envoy_<>_current_power_production', + 'state': '1.234', + }), + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_<>_energy_production_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'kWh', + }), + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy production today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_production', + 'unique_id': '<>_daily_production', + 'unit_of_measurement': 'kWh', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy <> Energy production today', + 'state_class': 'total_increasing', + 'unit_of_measurement': 'kWh', + }), + 'entity_id': 'sensor.envoy_<>_energy_production_today', + 'state': '1.234', + }), + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_<>_energy_production_last_seven_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'kWh', + }), + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy production last seven days', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'seven_days_production', + 'unique_id': '<>_seven_days_production', + 'unit_of_measurement': 'kWh', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy <> Energy production last seven days', + 'unit_of_measurement': 'kWh', + }), + 'entity_id': 'sensor.envoy_<>_energy_production_last_seven_days', + 'state': '1.234', + }), + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_<>_lifetime_energy_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'MWh', + }), + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Lifetime energy production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_production', + 'unique_id': '<>_lifetime_production', + 'unit_of_measurement': 'MWh', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy <> Lifetime energy production', + 'state_class': 'total_increasing', + 'unit_of_measurement': 'MWh', + }), + 'entity_id': 'sensor.envoy_<>_lifetime_energy_production', + 'state': '0.00<>', + }), + }), + ]), + }), + ]), + 'envoy_model_data': dict({ + 'ctmeter_consumption': None, + 'ctmeter_consumption_phases': None, + 'ctmeter_production': None, + 'ctmeter_production_phases': None, + 'ctmeter_storage': None, + 'ctmeter_storage_phases': None, + 'dry_contact_settings': dict({ + }), + 'dry_contact_status': dict({ + }), + 'encharge_aggregate': None, + 'encharge_inventory': None, + 'encharge_power': None, + 'enpower': None, + 'inverters': dict({ + '1': dict({ + '__type': "", + 'repr': "EnvoyInverter(serial_number='1', last_report_date=1, last_report_watts=1, max_report_watts=1)", + }), + }), + 'system_consumption': None, + 'system_consumption_phases': None, + 'system_production': dict({ + '__type': "", + 'repr': 'EnvoySystemProduction(watt_hours_lifetime=1234, watt_hours_last_7_days=1234, watt_hours_today=1234, watts_now=1234)', + }), + 'system_production_phases': None, + 'tariff': None, + }), + 'envoy_properties': dict({ + 'active interface': dict({ + 'envoy timezone': 'Europe/Amsterdam', + 'firmware build date': '2024-06-27 15:59:26', + 'interface type': 'ethernet', + 'mac': '00:11:22:33:44:55', + 'name': 'eth0', + 'uses dhcp': True, + }), 'active_phasecount': 0, 'ct_consumption_meter': None, 'ct_count': 0, @@ -1373,7 +1902,6 @@ ]), }), 'fixtures': dict({ - 'Error': "EnvoyError('Test')", }), 'raw_data': dict({ 'varies_by': 'firmware_version', diff --git a/tests/components/enphase_envoy/test_diagnostics.py b/tests/components/enphase_envoy/test_diagnostics.py index 186ee5c46f3..87e6842616d 100644 --- a/tests/components/enphase_envoy/test_diagnostics.py +++ b/tests/components/enphase_envoy/test_diagnostics.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory from pyenphase.exceptions import EnvoyError import pytest from syrupy.assertion import SnapshotAssertion @@ -10,11 +11,12 @@ from homeassistant.components.enphase_envoy.const import ( DOMAIN, OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, ) +from homeassistant.components.enphase_envoy.coordinator import MAC_VERIFICATION_DELAY from homeassistant.core import HomeAssistant from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -90,3 +92,24 @@ async def test_entry_diagnostics_with_fixtures_with_error( assert await get_diagnostics_for_config_entry( hass, hass_client, config_entry_options ) == snapshot(exclude=limit_diagnostic_attrs) + + +async def test_entry_diagnostics_with_interface_information( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test config entry diagnostics including interface data.""" + await setup_integration(hass, config_entry) + + # move time forward so interface information is collected + freezer.tick(MAC_VERIFICATION_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) == snapshot(exclude=limit_diagnostic_attrs) diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index 93a150cfc5c..ef071b421fe 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -19,6 +19,7 @@ from homeassistant.components.enphase_envoy.const import ( ) from homeassistant.components.enphase_envoy.coordinator import ( FIRMWARE_REFRESH_INTERVAL, + MAC_VERIFICATION_DELAY, SCAN_INTERVAL, ) from homeassistant.config_entries import ConfigEntryState @@ -443,3 +444,90 @@ async def test_coordinator_firmware_refresh_with_envoy_error( await hass.async_block_till_done(wait_background_tasks=True) assert "Error reading firmware:" in caplog.text + + +@respx.mock +async def test_coordinator_interface_information( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator interface mac verification.""" + await setup_integration(hass, config_entry) + + caplog.set_level(logging.DEBUG) + logging.getLogger("homeassistant.components.enphase_envoy.coordinator").setLevel( + logging.DEBUG + ) + + # move time forward so interface information is fetched + freezer.tick(MAC_VERIFICATION_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # verify first time add of mac to connections is in log + assert "added connection" in caplog.text + + # trigger integration reload by changing options + hass.config_entries.async_update_entry( + config_entry, + options={ + OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: False, + OPTION_DISABLE_KEEP_ALIVE: True, + }, + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + + caplog.clear() + # envoy reloaded and device registry still has connection info + # force mac verification again to test existing connection is verified + freezer.tick(MAC_VERIFICATION_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # verify existing connection is verified in log + assert "connection verified as existing" in caplog.text + + +@respx.mock +async def test_coordinator_interface_information_no_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, +) -> None: + """Test coordinator interface mac verification full code cov.""" + await setup_integration(hass, config_entry) + + caplog.set_level(logging.DEBUG) + logging.getLogger("homeassistant.components.enphase_envoy.coordinator").setLevel( + logging.DEBUG + ) + + # update device to force no device found in mac verification + device_registry = dr.async_get(hass) + envoy_device = device_registry.async_get_device( + identifiers={ + ( + DOMAIN, + mock_envoy.serial_number, + ) + } + ) + device_registry.async_update_device( + device_id=envoy_device.id, + new_identifiers={(DOMAIN, "9999")}, + ) + + # move time forward so interface information is fetched + freezer.tick(MAC_VERIFICATION_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # verify no device found message in log + assert "No envoy device found in device registry" in caplog.text diff --git a/tests/components/esphome/common.py b/tests/components/esphome/common.py new file mode 100644 index 00000000000..426eee11341 --- /dev/null +++ b/tests/components/esphome/common.py @@ -0,0 +1,57 @@ +"""ESPHome test common code.""" + +from datetime import datetime + +from homeassistant.components import assist_satellite +from homeassistant.components.assist_satellite import AssistSatelliteEntity + +# pylint: disable-next=hass-component-root-import +from homeassistant.components.esphome import DOMAIN +from homeassistant.components.esphome.assist_satellite import EsphomeAssistSatellite +from homeassistant.components.esphome.coordinator import REFRESH_INTERVAL +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed + + +class MockDashboardRefresh: + """Mock dashboard refresh.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the mock dashboard refresh.""" + self.hass = hass + self.last_time: datetime | None = None + + async def async_refresh(self) -> None: + """Refresh the dashboard.""" + if self.last_time is None: + self.last_time = dt_util.utcnow() + self.last_time += REFRESH_INTERVAL + async_fire_time_changed(self.hass, self.last_time) + await self.hass.async_block_till_done() + + +def get_satellite_entity( + hass: HomeAssistant, mac_address: str +) -> EsphomeAssistSatellite | None: + """Get the satellite entity for a device.""" + ent_reg = er.async_get(hass) + satellite_entity_id = ent_reg.async_get_entity_id( + Platform.ASSIST_SATELLITE, DOMAIN, f"{mac_address}-assist_satellite" + ) + if satellite_entity_id is None: + return None + assert satellite_entity_id.endswith("_assist_satellite") + + component: EntityComponent[AssistSatelliteEntity] = hass.data[ + assist_satellite.DOMAIN + ] + if (entity := component.get_entity(satellite_entity_id)) is not None: + assert isinstance(entity, EsphomeAssistSatellite) + return entity + + return None diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 2786ed8324c..08a581be6d9 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -4,9 +4,9 @@ from __future__ import annotations import asyncio from asyncio import Event -from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine +from collections.abc import AsyncGenerator, Callable, Coroutine, Generator from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Protocol from unittest.mock import AsyncMock, MagicMock, Mock, patch from aioesphomeapi import ( @@ -48,6 +48,46 @@ if TYPE_CHECKING: from aioesphomeapi.api_pb2 import SubscribeLogsResponse +class MockGenericDeviceEntryType(Protocol): + """Mock ESPHome device entry type.""" + + async def __call__( + self, + mock_client: APIClient, + entity_info: list[EntityInfo], + user_service: list[UserService], + states: list[EntityState], + mock_storage: bool = ..., + ) -> MockConfigEntry: + """Mock an ESPHome device entry.""" + + +class MockESPHomeDeviceType(Protocol): + """Mock ESPHome device type.""" + + async def __call__( + self, + mock_client: APIClient, + entity_info: list[EntityInfo] | None = ..., + user_service: list[UserService] | None = ..., + states: list[EntityState] | None = ..., + entry: MockConfigEntry | None = ..., + device_info: dict[str, Any] | None = ..., + mock_storage: bool = ..., + ) -> MockESPHomeDevice: + """Mock an ESPHome device.""" + + +class MockBluetoothEntryType(Protocol): + """Mock ESPHome bluetooth entry type.""" + + async def __call__( + self, + bluetooth_proxy_feature_flags: BluetoothProxyFeature, + ) -> MockESPHomeDevice: + """Mock an ESPHome bluetooth entry.""" + + _ONE_SECOND = 16000 * 2 # 16Khz 16-bit @@ -133,7 +173,7 @@ async def init_integration( @pytest.fixture -def mock_client(mock_device_info) -> APIClient: +def mock_client(mock_device_info) -> Generator[APIClient]: """Mock APIClient.""" mock_client = Mock(spec=APIClient) @@ -573,7 +613,7 @@ async def mock_voice_assistant_api_entry(mock_voice_assistant_entry) -> MockConf async def mock_bluetooth_entry( hass: HomeAssistant, mock_client: APIClient, -): +) -> MockBluetoothEntryType: """Set up an ESPHome entry with bluetooth.""" async def _mock_bluetooth_entry( @@ -608,7 +648,9 @@ async def mock_bluetooth_entry( @pytest.fixture -async def mock_bluetooth_entry_with_raw_adv(mock_bluetooth_entry) -> MockESPHomeDevice: +async def mock_bluetooth_entry_with_raw_adv( + mock_bluetooth_entry: MockBluetoothEntryType, +) -> MockESPHomeDevice: """Set up an ESPHome entry with bluetooth and raw advertisements.""" return await mock_bluetooth_entry( bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN @@ -622,7 +664,7 @@ async def mock_bluetooth_entry_with_raw_adv(mock_bluetooth_entry) -> MockESPHome @pytest.fixture async def mock_bluetooth_entry_with_legacy_adv( - mock_bluetooth_entry, + mock_bluetooth_entry: MockBluetoothEntryType, ) -> MockESPHomeDevice: """Set up an ESPHome entry with bluetooth with legacy advertisements.""" return await mock_bluetooth_entry( @@ -638,10 +680,7 @@ async def mock_bluetooth_entry_with_legacy_adv( async def mock_generic_device_entry( hass: HomeAssistant, hass_storage: dict[str, Any], -) -> Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockConfigEntry], -]: +) -> MockGenericDeviceEntryType: """Set up an ESPHome entry and return the MockConfigEntry.""" async def _mock_device_entry( @@ -670,10 +709,7 @@ async def mock_generic_device_entry( async def mock_esphome_device( hass: HomeAssistant, hass_storage: dict[str, Any], -) -> Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], -]: +) -> MockESPHomeDeviceType: """Set up an ESPHome entry and return the MockESPHomeDevice.""" async def _mock_device( diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index 8f1711e829e..d88f2045e56 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -26,6 +26,85 @@ 'unique_id': '11:22:33:44:55:aa', 'version': 1, }), - 'dashboard': 'mock-slug', + 'dashboard': dict({ + 'addon': 'mock-slug', + 'configured': True, + 'last_exception': None, + 'last_update_success': True, + 'supports_update': None, + }), + }) +# --- +# name: test_diagnostics_with_dashboard_data + dict({ + 'config': dict({ + 'data': dict({ + 'device_name': 'test', + 'host': 'test.local', + 'password': '', + 'port': 6053, + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'esphome', + 'minor_version': 1, + 'options': dict({ + 'allow_service_calls': False, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': '11:22:33:44:55:aa', + 'version': 1, + }), + 'dashboard': dict({ + 'addon': 'mock-slug', + 'configured': True, + 'device': dict({ + 'configuration': 'test.yaml', + 'current_version': '2023.1.0', + 'deployed_version': None, + 'loaded_integrations': None, + 'target_platform': None, + }), + 'has_matching_name': True, + 'last_exception': None, + 'last_update_success': True, + 'supports_update': False, + }), + 'storage_data': dict({ + 'api_version': dict({ + 'major': 99, + 'minor': 99, + }), + 'device_info': dict({ + 'bluetooth_mac_address': '', + 'bluetooth_proxy_feature_flags': 0, + 'compilation_time': '', + 'esphome_version': '1.0.0', + 'friendly_name': 'Test', + 'has_deep_sleep': False, + 'legacy_bluetooth_proxy_version': 0, + 'legacy_voice_assistant_version': 0, + 'mac_address': '**REDACTED**', + 'manufacturer': '', + 'model': '', + 'name': 'test', + 'project_name': '', + 'project_version': '', + 'suggested_area': '', + 'uses_password': False, + 'voice_assistant_feature_flags': 0, + 'webserver_port': 0, + }), + 'services': list([ + ]), + 'update': list([ + ]), + }), }) # --- diff --git a/tests/components/esphome/test_alarm_control_panel.py b/tests/components/esphome/test_alarm_control_panel.py index a3bfc72f3e2..5a90086eac0 100644 --- a/tests/components/esphome/test_alarm_control_panel.py +++ b/tests/components/esphome/test_alarm_control_panel.py @@ -26,11 +26,13 @@ from homeassistant.components.esphome.alarm_control_panel import EspHomeACPFeatu from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + async def test_generic_alarm_control_panel_requires_code( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic alarm_control_panel entity that requires a code.""" entity_info = [ @@ -163,7 +165,7 @@ async def test_generic_alarm_control_panel_requires_code( async def test_generic_alarm_control_panel_no_code( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic alarm_control_panel entity that does not require a code.""" entity_info = [ @@ -209,7 +211,7 @@ async def test_generic_alarm_control_panel_no_code( async def test_generic_alarm_control_panel_missing_state( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic alarm_control_panel entity that is missing state.""" entity_info = [ diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index ce5de0a1a67..50ce362d7b6 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -1,7 +1,6 @@ """Test ESPHome voice assistant server.""" import asyncio -from collections.abc import Awaitable, Callable from dataclasses import replace import io import socket @@ -10,12 +9,9 @@ import wave from aioesphomeapi import ( APIClient, - EntityInfo, - EntityState, MediaPlayerFormatPurpose, MediaPlayerInfo, MediaPlayerSupportedFormat, - UserService, VoiceAssistantAnnounceFinished, VoiceAssistantAudioSettings, VoiceAssistantCommandFlag, @@ -34,58 +30,28 @@ from homeassistant.components import ( from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite import ( AssistSatelliteConfiguration, - AssistSatelliteEntity, AssistSatelliteEntityFeature, AssistSatelliteWakeWord, ) # pylint: disable-next=hass-component-root-import from homeassistant.components.assist_satellite.entity import AssistSatelliteState -from homeassistant.components.esphome import DOMAIN -from homeassistant.components.esphome.assist_satellite import ( - EsphomeAssistSatellite, - VoiceAssistantUDPServer, -) +from homeassistant.components.esphome.assist_satellite import VoiceAssistantUDPServer from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - intent as intent_helper, -) -from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers import device_registry as dr, intent as intent_helper +from homeassistant.helpers.network import get_url -from .conftest import MockESPHomeDevice +from .common import get_satellite_entity +from .conftest import MockESPHomeDeviceType from tests.components.tts.common import MockResultStream -def get_satellite_entity( - hass: HomeAssistant, mac_address: str -) -> EsphomeAssistSatellite | None: - """Get the satellite entity for a device.""" - ent_reg = er.async_get(hass) - satellite_entity_id = ent_reg.async_get_entity_id( - Platform.ASSIST_SATELLITE, DOMAIN, f"{mac_address}-assist_satellite" - ) - if satellite_entity_id is None: - return None - assert satellite_entity_id.endswith("_assist_satellite") - - component: EntityComponent[AssistSatelliteEntity] = hass.data[ - assist_satellite.DOMAIN - ] - if (entity := component.get_entity(satellite_entity_id)) is not None: - assert isinstance(entity, EsphomeAssistSatellite) - return entity - - return None - - @pytest.fixture def mock_wav() -> bytes: """Return test WAV audio.""" @@ -102,13 +68,10 @@ def mock_wav() -> bytes: async def test_no_satellite_without_voice_assistant( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test that an assist satellite entity is not created if a voice assistant is not present.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -125,18 +88,13 @@ async def test_pipeline_api_audio( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, mock_wav: bytes, ) -> None: """Test a complete pipeline run with API audio (over the TCP connection).""" conversation_id = "test-conversation-id" - media_url = "http://test.url" - media_id = "test-media-id" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -328,15 +286,39 @@ async def test_pipeline_api_audio( assert satellite.state == AssistSatelliteState.RESPONDING # Should return mock_wav audio + mock_tts_result_stream = MockResultStream(hass, "wav", mock_wav) event_callback( PipelineEvent( type=PipelineEventType.TTS_END, - data={"tts_output": {"url": media_url, "media_id": media_id}}, + data={ + "tts_output": { + "media_id": "test-media-id", + "url": mock_tts_result_stream.url, + "token": mock_tts_result_stream.token, + } + }, ) ) assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END, - {"url": media_url}, + {"url": get_url(hass) + mock_tts_result_stream.url}, + ) + + event_callback( + PipelineEvent( + type=PipelineEventType.RUN_START, + data={ + "tts_output": { + "media_id": "test-media-id", + "url": mock_tts_result_stream.url, + "token": mock_tts_result_stream.token, + } + }, + ) + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START, + {"url": get_url(hass) + mock_tts_result_stream.url}, ) event_callback(PipelineEvent(type=PipelineEventType.RUN_END)) @@ -355,12 +337,6 @@ async def test_pipeline_api_audio( original_handle_pipeline_finished() pipeline_finished.set() - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - return ("wav", mock_wav) - tts_finished = asyncio.Event() original_tts_response_finished = satellite.tts_response_finished @@ -373,10 +349,6 @@ async def test_pipeline_api_audio( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), patch.object(satellite, "handle_pipeline_finished", handle_pipeline_finished), patch.object(satellite, "_stream_tts_audio", _stream_tts_audio), patch.object(satellite, "tts_response_finished", tts_response_finished), @@ -422,10 +394,7 @@ async def test_pipeline_api_audio( async def test_pipeline_udp_audio( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, mock_wav: bytes, ) -> None: """Test a complete pipeline run with legacy UDP audio. @@ -434,10 +403,8 @@ async def test_pipeline_udp_audio( mainly focused on the UDP server. """ conversation_id = "test-conversation-id" - media_url = "http://test.url" - media_id = "test-media-id" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -522,10 +489,17 @@ async def test_pipeline_udp_audio( ) # Should return mock_wav audio + mock_tts_result_stream = MockResultStream(hass, "wav", mock_wav) event_callback( PipelineEvent( type=PipelineEventType.TTS_END, - data={"tts_output": {"url": media_url, "media_id": media_id}}, + data={ + "tts_output": { + "media_id": "test-media-id", + "url": mock_tts_result_stream.url, + "token": mock_tts_result_stream.token, + } + }, ) ) @@ -538,12 +512,6 @@ async def test_pipeline_udp_audio( original_handle_pipeline_finished() pipeline_finished.set() - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - return ("wav", mock_wav) - tts_finished = asyncio.Event() original_tts_response_finished = satellite.tts_response_finished @@ -567,10 +535,6 @@ async def test_pipeline_udp_audio( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), patch.object(satellite, "handle_pipeline_finished", handle_pipeline_finished), patch.object(satellite, "tts_response_finished", tts_response_finished), ): @@ -640,10 +604,7 @@ async def test_udp_errors() -> None: async def test_pipeline_media_player( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, mock_wav: bytes, ) -> None: """Test a complete pipeline run with the TTS response sent to a media player instead of a speaker. @@ -652,10 +613,8 @@ async def test_pipeline_media_player( mainly focused on tts_response_finished getting automatically called. """ conversation_id = "test-conversation-id" - media_url = "http://test.url" - media_id = "test-media-id" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -733,10 +692,17 @@ async def test_pipeline_media_player( ) # Should return mock_wav audio + mock_tts_result_stream = MockResultStream(hass, "wav", mock_wav) event_callback( PipelineEvent( type=PipelineEventType.TTS_END, - data={"tts_output": {"url": media_url, "media_id": media_id}}, + data={ + "tts_output": { + "media_id": "test-media-id", + "url": mock_tts_result_stream.url, + "token": mock_tts_result_stream.token, + } + }, ) ) @@ -749,12 +715,6 @@ async def test_pipeline_media_player( original_handle_pipeline_finished() pipeline_finished.set() - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - return ("wav", mock_wav) - tts_finished = asyncio.Event() original_tts_response_finished = satellite.tts_response_finished @@ -767,10 +727,6 @@ async def test_pipeline_media_player( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), patch.object(satellite, "handle_pipeline_finished", handle_pipeline_finished), patch.object(satellite, "tts_response_finished", tts_response_finished), ): @@ -800,14 +756,11 @@ async def test_timer_events( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test that injecting timer events results in the correct api client calls.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -874,14 +827,11 @@ async def test_unknown_timer_event( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test that unknown (new) timer event types do not result in api calls.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -921,14 +871,11 @@ async def test_unknown_timer_event( async def test_streaming_tts_errors( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, mock_wav: bytes, ) -> None: """Test error conditions for _stream_tts_audio function.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -944,92 +891,72 @@ async def test_streaming_tts_errors( # Should not stream if not running satellite._is_running = False - await satellite._stream_tts_audio("test-media-id") + await satellite._stream_tts_audio(MockResultStream(hass, "wav", mock_wav)) mock_client.send_voice_assistant_audio.assert_not_called() satellite._is_running = True # Should only stream WAV - async def get_mp3( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - return ("mp3", b"") - - with patch( - "homeassistant.components.tts.async_get_media_source_audio", new=get_mp3 - ): - await satellite._stream_tts_audio("test-media-id") - mock_client.send_voice_assistant_audio.assert_not_called() + await satellite._stream_tts_audio(MockResultStream(hass, "mp3", b"")) + mock_client.send_voice_assistant_audio.assert_not_called() # Needs to be the correct sample rate, etc. - async def get_bad_wav( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - with io.BytesIO() as wav_io: - with wave.open(wav_io, "wb") as wav_file: - wav_file.setframerate(48000) - wav_file.setsampwidth(2) - wav_file.setnchannels(1) - wav_file.writeframes(b"test-wav") + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(48000) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(b"test-wav") - return ("wav", wav_io.getvalue()) + mock_tts_result_stream = MockResultStream(hass, "wav", wav_io.getvalue()) - with patch( - "homeassistant.components.tts.async_get_media_source_audio", new=get_bad_wav - ): - await satellite._stream_tts_audio("test-media-id") - mock_client.send_voice_assistant_audio.assert_not_called() + await satellite._stream_tts_audio(mock_tts_result_stream) + mock_client.send_voice_assistant_audio.assert_not_called() # Check that TTS_STREAM_* events still get sent after cancel media_fetched = asyncio.Event() - async def get_slow_wav( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: + mock_tts_result_stream = MockResultStream(hass, "wav", b"") + + async def async_stream_result_slowly(): media_fetched.set() await asyncio.sleep(1) - return ("wav", mock_wav) + yield mock_wav + + mock_tts_result_stream.async_stream_result = async_stream_result_slowly mock_client.send_voice_assistant_event.reset_mock() - with patch( - "homeassistant.components.tts.async_get_media_source_audio", new=get_slow_wav - ): - task = asyncio.create_task(satellite._stream_tts_audio("test-media-id")) - async with asyncio.timeout(1): - # Wait for media to be fetched - await media_fetched.wait() - # Cancel task - task.cancel() - await task + task = asyncio.create_task(satellite._stream_tts_audio(mock_tts_result_stream)) + async with asyncio.timeout(1): + # Wait for media to be fetched + await media_fetched.wait() - # No audio should have gone out - mock_client.send_voice_assistant_audio.assert_not_called() - assert len(mock_client.send_voice_assistant_event.call_args_list) == 2 + # Cancel task + task.cancel() + await task - # The TTS_STREAM_* events should have gone out - assert mock_client.send_voice_assistant_event.call_args_list[-2].args == ( - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, - {}, - ) - assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, - {}, - ) + # No audio should have gone out + mock_client.send_voice_assistant_audio.assert_not_called() + assert len(mock_client.send_voice_assistant_event.call_args_list) == 2 + + # The TTS_STREAM_* events should have gone out + assert mock_client.send_voice_assistant_event.call_args_list[-2].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, + {}, + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, + {}, + ) async def test_tts_format_from_media_player( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test that the text-to-speech format is pulled from the first media player.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[ MediaPlayerInfo( @@ -1093,13 +1020,10 @@ async def test_tts_format_from_media_player( async def test_tts_minimal_format_from_media_player( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test text-to-speech format when media player only specifies the codec.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[ MediaPlayerInfo( @@ -1157,42 +1081,13 @@ async def test_tts_minimal_format_from_media_player( } -async def test_announce_supported_features( - hass: HomeAssistant, - mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], -) -> None: - """Test that the announce supported feature is not set by default.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( - mock_client=mock_client, - entity_info=[], - user_service=[], - states=[], - device_info={ - "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT - }, - ) - await hass.async_block_till_done() - - satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) - assert satellite is not None - - assert not (satellite.supported_features & AssistSatelliteEntityFeature.ANNOUNCE) - - async def test_announce_message( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test announcement with message.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -1250,7 +1145,7 @@ async def test_announce_message( assist_satellite.DOMAIN, "announce", { - "entity_id": satellite.entity_id, + ATTR_ENTITY_ID: satellite.entity_id, "message": "test-text", "preannounce": False, }, @@ -1263,14 +1158,11 @@ async def test_announce_message( async def test_announce_media_id( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, device_registry: dr.DeviceRegistry, ) -> None: """Test announcement with media id.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[ MediaPlayerInfo( @@ -1340,7 +1232,7 @@ async def test_announce_media_id( assist_satellite.DOMAIN, "announce", { - "entity_id": satellite.entity_id, + ATTR_ENTITY_ID: satellite.entity_id, "media_id": "https://www.home-assistant.io/resolved.mp3", "preannounce": False, }, @@ -1363,13 +1255,10 @@ async def test_announce_media_id( async def test_announce_message_with_preannounce( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test announcement with message and preannounce media id.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -1427,7 +1316,7 @@ async def test_announce_message_with_preannounce( assist_satellite.DOMAIN, "announce", { - "entity_id": satellite.entity_id, + ATTR_ENTITY_ID: satellite.entity_id, "message": "test-text", "preannounce_media_id": "test-preannounce", }, @@ -1437,16 +1326,13 @@ async def test_announce_message_with_preannounce( assert satellite.state == AssistSatelliteState.IDLE -async def test_start_conversation_supported_features( +async def test_non_default_supported_features( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: - """Test that the start conversation supported feature is not set by default.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + """Test that the start conversation and announce are not set by default.""" + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -1463,18 +1349,16 @@ async def test_start_conversation_supported_features( assert not ( satellite.supported_features & AssistSatelliteEntityFeature.START_CONVERSATION ) + assert not (satellite.supported_features & AssistSatelliteEntityFeature.ANNOUNCE) async def test_start_conversation_message( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test start conversation with message.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -1551,7 +1435,7 @@ async def test_start_conversation_message( assist_satellite.DOMAIN, "start_conversation", { - "entity_id": satellite.entity_id, + ATTR_ENTITY_ID: satellite.entity_id, "start_message": "test-text", "preannounce": False, }, @@ -1564,14 +1448,11 @@ async def test_start_conversation_message( async def test_start_conversation_media_id( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, device_registry: dr.DeviceRegistry, ) -> None: """Test start conversation with media id.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[ MediaPlayerInfo( @@ -1660,7 +1541,7 @@ async def test_start_conversation_media_id( assist_satellite.DOMAIN, "start_conversation", { - "entity_id": satellite.entity_id, + ATTR_ENTITY_ID: satellite.entity_id, "start_media_id": "https://www.home-assistant.io/resolved.mp3", "preannounce": False, }, @@ -1683,13 +1564,10 @@ async def test_start_conversation_media_id( async def test_start_conversation_message_with_preannounce( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test start conversation with message and preannounce media id.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -1766,7 +1644,7 @@ async def test_start_conversation_message_with_preannounce( assist_satellite.DOMAIN, "start_conversation", { - "entity_id": satellite.entity_id, + ATTR_ENTITY_ID: satellite.entity_id, "start_message": "test-text", "preannounce_media_id": "test-preannounce", }, @@ -1779,13 +1657,10 @@ async def test_start_conversation_message_with_preannounce( async def test_satellite_unloaded_on_disconnect( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test that the assist satellite platform is unloaded on disconnect.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -1814,13 +1689,10 @@ async def test_satellite_unloaded_on_disconnect( async def test_pipeline_abort( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test aborting a pipeline (no further processing).""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -1891,10 +1763,7 @@ async def test_pipeline_abort( async def test_get_set_configuration( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test getting and setting the satellite configuration.""" expected_config = AssistSatelliteConfiguration( @@ -1907,7 +1776,7 @@ async def test_get_set_configuration( ) mock_client.get_voice_assistant_configuration.return_value = expected_config - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -1944,10 +1813,7 @@ async def test_get_set_configuration( async def test_wake_word_select( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test wake word select.""" device_config = AssistSatelliteConfiguration( @@ -1971,7 +1837,7 @@ async def test_wake_word_select( mock_client.set_voice_assistant_configuration = AsyncMock(side_effect=wrapper) - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -1996,7 +1862,7 @@ async def test_wake_word_select( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {"entity_id": "select.test_wake_word", "option": "Okay Nabu"}, + {ATTR_ENTITY_ID: "select.test_wake_word", "option": "Okay Nabu"}, blocking=True, ) await hass.async_block_till_done() @@ -2011,122 +1877,3 @@ async def test_wake_word_select( # Satellite config should have been updated assert satellite.async_get_configuration().active_wake_words == ["okay_nabu"] - - -async def test_wake_word_select_no_wake_words( - hass: HomeAssistant, - mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], -) -> None: - """Test wake word select is unavailable when there are no available wake word.""" - device_config = AssistSatelliteConfiguration( - available_wake_words=[], - active_wake_words=[], - max_active_wake_words=1, - ) - mock_client.get_voice_assistant_configuration.return_value = device_config - - mock_device: MockESPHomeDevice = await mock_esphome_device( - mock_client=mock_client, - entity_info=[], - user_service=[], - states=[], - device_info={ - "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT - | VoiceAssistantFeature.ANNOUNCE - }, - ) - await hass.async_block_till_done() - - satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) - assert satellite is not None - assert not satellite.async_get_configuration().available_wake_words - - # Select should be unavailable - state = hass.states.get("select.test_wake_word") - assert state is not None - assert state.state == STATE_UNAVAILABLE - - -async def test_wake_word_select_zero_max_wake_words( - hass: HomeAssistant, - mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], -) -> None: - """Test wake word select is unavailable max wake words is zero.""" - device_config = AssistSatelliteConfiguration( - available_wake_words=[ - AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), - ], - active_wake_words=[], - max_active_wake_words=0, - ) - mock_client.get_voice_assistant_configuration.return_value = device_config - - mock_device: MockESPHomeDevice = await mock_esphome_device( - mock_client=mock_client, - entity_info=[], - user_service=[], - states=[], - device_info={ - "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT - | VoiceAssistantFeature.ANNOUNCE - }, - ) - await hass.async_block_till_done() - - satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) - assert satellite is not None - assert satellite.async_get_configuration().max_active_wake_words == 0 - - # Select should be unavailable - state = hass.states.get("select.test_wake_word") - assert state is not None - assert state.state == STATE_UNAVAILABLE - - -async def test_wake_word_select_no_active_wake_words( - hass: HomeAssistant, - mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], -) -> None: - """Test wake word select uses first available wake word if none are active.""" - device_config = AssistSatelliteConfiguration( - available_wake_words=[ - AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), - AssistSatelliteWakeWord("hey_jarvis", "Hey Jarvis", ["en"]), - ], - active_wake_words=[], - max_active_wake_words=1, - ) - mock_client.get_voice_assistant_configuration.return_value = device_config - - mock_device: MockESPHomeDevice = await mock_esphome_device( - mock_client=mock_client, - entity_info=[], - user_service=[], - states=[], - device_info={ - "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT - | VoiceAssistantFeature.ANNOUNCE - }, - ) - await hass.async_block_till_done() - - satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) - assert satellite is not None - assert not satellite.async_get_configuration().active_wake_words - - # First available wake word should be selected - state = hass.states.get("select.test_wake_word") - assert state is not None - assert state.state == "Okay Nabu" diff --git a/tests/components/esphome/test_binary_sensor.py b/tests/components/esphome/test_binary_sensor.py index 25d8b60f574..fee285ea312 100644 --- a/tests/components/esphome/test_binary_sensor.py +++ b/tests/components/esphome/test_binary_sensor.py @@ -1,178 +1,12 @@ """Test ESPHome binary sensors.""" -from collections.abc import Awaitable, Callable -from http import HTTPStatus - -from aioesphomeapi import ( - APIClient, - BinarySensorInfo, - BinarySensorState, - EntityInfo, - EntityState, - UserService, -) +from aioesphomeapi import APIClient, BinarySensorInfo, BinarySensorState import pytest -from homeassistant.components.esphome import DOMAIN, DomainData -from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.setup import async_setup_component -from .conftest import MockESPHomeDevice - -from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_assist_in_progress( - hass: HomeAssistant, - mock_voice_assistant_v1_entry, -) -> None: - """Test assist in progress binary sensor.""" - - entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_v1_entry) - - state = hass.states.get("binary_sensor.test_assist_in_progress") - assert state is not None - assert state.state == "off" - - entry_data.async_set_assist_pipeline_state(True) - - state = hass.states.get("binary_sensor.test_assist_in_progress") - assert state.state == "on" - - entry_data.async_set_assist_pipeline_state(False) - - state = hass.states.get("binary_sensor.test_assist_in_progress") - assert state.state == "off" - - -async def test_assist_in_progress_disabled_by_default( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - mock_voice_assistant_v1_entry, -) -> None: - """Test assist in progress binary sensor is added disabled.""" - - assert not hass.states.get("binary_sensor.test_assist_in_progress") - entity_entry = entity_registry.async_get("binary_sensor.test_assist_in_progress") - assert entity_entry - assert entity_entry.disabled - assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Test no issue for disabled entity - assert len(issue_registry.issues) == 0 - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_assist_in_progress_issue( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - mock_voice_assistant_v1_entry, -) -> None: - """Test assist in progress binary sensor.""" - - state = hass.states.get("binary_sensor.test_assist_in_progress") - assert state is not None - - entity_entry = entity_registry.async_get("binary_sensor.test_assist_in_progress") - issue = issue_registry.async_get_issue( - DOMAIN, f"assist_in_progress_deprecated_{entity_entry.id}" - ) - assert issue is not None - - # Test issue goes away after disabling the entity - entity_registry.async_update_entity( - "binary_sensor.test_assist_in_progress", - disabled_by=er.RegistryEntryDisabler.USER, - ) - await hass.async_block_till_done() - issue = issue_registry.async_get_issue( - DOMAIN, f"assist_in_progress_deprecated_{entity_entry.id}" - ) - assert issue is None - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_assist_in_progress_repair_flow( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - mock_voice_assistant_v1_entry, -) -> None: - """Test assist in progress binary sensor deprecation issue flow.""" - - state = hass.states.get("binary_sensor.test_assist_in_progress") - assert state is not None - - entity_entry = entity_registry.async_get("binary_sensor.test_assist_in_progress") - assert entity_entry.disabled_by is None - issue = issue_registry.async_get_issue( - DOMAIN, f"assist_in_progress_deprecated_{entity_entry.id}" - ) - assert issue is not None - assert issue.data == { - "entity_id": "binary_sensor.test_assist_in_progress", - "entity_uuid": entity_entry.id, - "integration_name": "ESPHome", - } - assert issue.translation_key == "assist_in_progress_deprecated" - assert issue.translation_placeholders == {"integration_name": "ESPHome"} - - assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) - await hass.async_block_till_done() - await hass.async_start() - - client = await hass_client() - - resp = await client.post( - "/api/repairs/issues/fix", - json={"handler": DOMAIN, "issue_id": issue.issue_id}, - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - flow_id = data["flow_id"] - assert data == { - "data_schema": [], - "description_placeholders": { - "assist_satellite_domain": "assist_satellite", - "entity_id": "binary_sensor.test_assist_in_progress", - "integration_name": "ESPHome", - }, - "errors": None, - "flow_id": flow_id, - "handler": DOMAIN, - "last_step": None, - "preview": None, - "step_id": "confirm_disable_entity", - "type": "form", - } - - resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - flow_id = data["flow_id"] - assert data == { - "description": None, - "description_placeholders": None, - "flow_id": flow_id, - "handler": DOMAIN, - "type": "create_entry", - } - - # Test the entity is disabled - entity_entry = entity_registry.async_get("binary_sensor.test_assist_in_progress") - assert entity_entry.disabled_by is er.RegistryEntryDisabler.USER +from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType @pytest.mark.parametrize( @@ -182,10 +16,7 @@ async def test_binary_sensor_generic_entity( hass: HomeAssistant, mock_client: APIClient, binary_state: tuple[bool, str], - mock_generic_device_entry: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockConfigEntry], - ], + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic binary_sensor entity.""" entity_info = [ @@ -213,10 +44,7 @@ async def test_binary_sensor_generic_entity( async def test_status_binary_sensor( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockConfigEntry], - ], + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic binary_sensor entity.""" entity_info = [ @@ -244,10 +72,7 @@ async def test_status_binary_sensor( async def test_binary_sensor_missing_state( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockConfigEntry], - ], + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic binary_sensor that is missing state.""" entity_info = [ @@ -274,10 +99,7 @@ async def test_binary_sensor_missing_state( async def test_binary_sensor_has_state_false( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a generic binary_sensor where has_state is false.""" entity_info = [ diff --git a/tests/components/esphome/test_camera.py b/tests/components/esphome/test_camera.py index 87b86b039fd..b03d2bb7983 100644 --- a/tests/components/esphome/test_camera.py +++ b/tests/components/esphome/test_camera.py @@ -1,21 +1,12 @@ """Test ESPHome cameras.""" -from collections.abc import Awaitable, Callable - -from aioesphomeapi import ( - APIClient, - CameraInfo, - CameraState as ESPHomeCameraState, - EntityInfo, - EntityState, - UserService, -) +from aioesphomeapi import APIClient, CameraInfo, CameraState as ESPHomeCameraState from homeassistant.components.camera import CameraState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from .conftest import MockESPHomeDevice +from .conftest import MockESPHomeDeviceType from tests.typing import ClientSessionGenerator @@ -30,10 +21,7 @@ SMALLEST_VALID_JPEG_BYTES = bytes.fromhex(SMALLEST_VALID_JPEG) async def test_camera_single_image( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, hass_client: ClientSessionGenerator, ) -> None: """Test a generic camera single image request.""" @@ -78,10 +66,7 @@ async def test_camera_single_image( async def test_camera_single_image_unavailable_before_requested( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, hass_client: ClientSessionGenerator, ) -> None: """Test a generic camera that goes unavailable before the request.""" @@ -119,10 +104,7 @@ async def test_camera_single_image_unavailable_before_requested( async def test_camera_single_image_unavailable_during_request( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, hass_client: ClientSessionGenerator, ) -> None: """Test a generic camera that goes unavailable before the request.""" @@ -164,10 +146,7 @@ async def test_camera_single_image_unavailable_during_request( async def test_camera_stream( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, hass_client: ClientSessionGenerator, ) -> None: """Test a generic camera stream.""" @@ -224,10 +203,7 @@ async def test_camera_stream( async def test_camera_stream_unavailable( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, hass_client: ClientSessionGenerator, ) -> None: """Test a generic camera stream when the device is disconnected.""" @@ -264,10 +240,7 @@ async def test_camera_stream_unavailable( async def test_camera_stream_with_disconnection( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, hass_client: ClientSessionGenerator, ) -> None: """Test a generic camera stream that goes unavailable during the request.""" diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 03d2f78a5d2..739c2119bf0 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -44,9 +44,13 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from .conftest import MockGenericDeviceEntryType + async def test_climate_entity( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic climate entity.""" entity_info = [ @@ -94,7 +98,9 @@ async def test_climate_entity( async def test_climate_entity_with_step_and_two_point( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic climate entity.""" entity_info = [ @@ -168,7 +174,9 @@ async def test_climate_entity_with_step_and_two_point( async def test_climate_entity_with_step_and_target_temp( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic climate entity.""" entity_info = [ @@ -318,7 +326,9 @@ async def test_climate_entity_with_step_and_target_temp( async def test_climate_entity_with_humidity( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic climate entity with humidity.""" entity_info = [ @@ -378,7 +388,9 @@ async def test_climate_entity_with_humidity( async def test_climate_entity_with_inf_value( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic climate entity with infinite temp.""" entity_info = [ @@ -433,7 +445,7 @@ async def test_climate_entity_with_inf_value( async def test_climate_entity_attributes( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, snapshot: SnapshotAssertion, ) -> None: """Test a climate entity sets correct attributes.""" @@ -489,7 +501,7 @@ async def test_climate_entity_attributes( async def test_climate_entity_attribute_current_temperature_unsupported( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a climate entity with current temperature unsupported.""" entity_info = [ diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index d48a1f40482..ead9167d258 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -27,6 +27,7 @@ from homeassistant.components.esphome.const import ( DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DOMAIN, ) +from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -36,6 +37,7 @@ from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import VALID_NOISE_PSK +from .conftest import MockGenericDeviceEntryType from tests.common import MockConfigEntry @@ -50,24 +52,33 @@ def mock_setup_entry(): yield -@pytest.mark.usefixtures("mock_zeroconf") +def get_flow_context(hass: HomeAssistant, result: ConfigFlowResult) -> dict[str, Any]: + """Get the flow context from the result of async_init or async_configure.""" + flow = next( + flow + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + + return flow["context"] + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_connection_works( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test we can finish a config flow.""" result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, - data=None, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - "esphome", - context={"source": config_entries.SOURCE_USER}, - data={CONF_HOST: "127.0.0.1", CONF_PORT: 80}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 80}, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -93,10 +104,8 @@ async def test_user_connection_works( assert mock_client.noise_psk is None -@pytest.mark.usefixtures("mock_zeroconf") -async def test_user_connection_updates_host( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_user_connection_updates_host(hass: HomeAssistant) -> None: """Test setup up the same name updates the host.""" entry = MockConfigEntry( domain=DOMAIN, @@ -105,7 +114,7 @@ async def test_user_connection_updates_host( ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None, ) @@ -113,20 +122,22 @@ async def test_user_connection_updates_host( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - "esphome", - context={"source": config_entries.SOURCE_USER}, - data={CONF_HOST: "127.0.0.1", CONF_PORT: 80}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 80}, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } assert entry.data[CONF_HOST] == "127.0.0.1" -@pytest.mark.usefixtures("mock_zeroconf") -async def test_user_sets_unique_id( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_user_sets_unique_id(hass: HomeAssistant) -> None: """Test that the user flow sets the unique id.""" service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), @@ -140,11 +151,14 @@ async def test_user_sets_unique_id( type="mock_type", ) discovery_result = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) assert discovery_result["type"] is FlowResultType.FORM assert discovery_result["step_id"] == "discovery_confirm" + assert discovery_result["description_placeholders"] == { + "name": "test8266", + } discovery_result = await hass.config_entries.flow.async_configure( discovery_result["flow_id"], @@ -160,7 +174,7 @@ async def test_user_sets_unique_id( } result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None, ) @@ -173,13 +187,16 @@ async def test_user_sets_unique_id( {CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "test", + "name": "test", + "mac": "11:22:33:44:55:aa", + } -@pytest.mark.usefixtures("mock_zeroconf") -async def test_user_resolve_error( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") +async def test_user_resolve_error(hass: HomeAssistant, mock_client: APIClient) -> None: """Test user step with IP resolve error.""" with patch( @@ -188,7 +205,7 @@ async def test_user_resolve_error( ) as exc: mock_client.device_info.side_effect = exc result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) @@ -201,11 +218,27 @@ async def test_user_resolve_error( assert len(mock_client.device_info.mock_calls) == 1 assert len(mock_client.disconnect.mock_calls) == 1 + # Now simulate the user retrying with the same host and a successful connection + mock_client.device_info.side_effect = None -@pytest.mark.usefixtures("mock_zeroconf") -async def test_user_causes_zeroconf_to_abort( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "test" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_DEVICE_NAME: "test", + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + } + + +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_user_causes_zeroconf_to_abort(hass: HomeAssistant) -> None: """Test that the user flow sets the unique id and aborts the zeroconf flow.""" service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), @@ -219,14 +252,17 @@ async def test_user_causes_zeroconf_to_abort( type="mock_type", ) discovery_result = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) assert discovery_result["type"] is FlowResultType.FORM assert discovery_result["step_id"] == "discovery_confirm" + assert discovery_result["description_placeholders"] == { + "name": "test8266", + } result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None, ) @@ -250,15 +286,16 @@ async def test_user_causes_zeroconf_to_abort( assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_connection_error( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, + mock_client: APIClient, ) -> None: """Test user step with connection error.""" mock_client.device_info.side_effect = APIConnectionError result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) @@ -271,22 +308,42 @@ async def test_user_connection_error( assert len(mock_client.device_info.mock_calls) == 1 assert len(mock_client.disconnect.mock_calls) == 1 + # Now simulate the user retrying with the same host and a successful connection + mock_client.device_info.side_effect = None -@pytest.mark.usefixtures("mock_zeroconf") + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "test" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_DEVICE_NAME: "test", + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + } + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_with_password( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, + mock_client: APIClient, ) -> None: """Test user step with password.""" mock_client.device_info.return_value = DeviceInfo(uses_password=True, name="test") result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "password1"} @@ -304,18 +361,21 @@ async def test_user_with_password( @pytest.mark.usefixtures("mock_zeroconf") -async def test_user_invalid_password(hass: HomeAssistant, mock_client) -> None: +async def test_user_invalid_password( + hass: HomeAssistant, mock_client: APIClient +) -> None: """Test user step with invalid password.""" mock_client.device_info.return_value = DeviceInfo(uses_password=True, name="test") result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} mock_client.connect.side_effect = InvalidAuthAPIError @@ -325,20 +385,35 @@ async def test_user_invalid_password(hass: HomeAssistant, mock_client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} assert result["errors"] == {"base": "invalid_auth"} + mock_client.connect.side_effect = None -@pytest.mark.usefixtures("mock_zeroconf") + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "good"} + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "test" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_DEVICE_NAME: "test", + CONF_PASSWORD: "good", + CONF_NOISE_PSK: "", + } + + +@pytest.mark.usefixtures("mock_dashboard", "mock_setup_entry", "mock_zeroconf") async def test_user_dashboard_has_wrong_key( hass: HomeAssistant, - mock_client, - mock_dashboard: dict[str, Any], - mock_setup_entry: None, + mock_client: APIClient, ) -> None: """Test user step with key from dashboard that is incorrect.""" mock_client.device_info.side_effect = [ RequiresEncryptionAPIError, - InvalidEncryptionKeyAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test"), DeviceInfo( uses_password=False, name="test", @@ -351,7 +426,7 @@ async def test_user_dashboard_has_wrong_key( return_value=WRONG_NOISE_PSK, ): result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) @@ -359,6 +434,7 @@ async def test_user_dashboard_has_wrong_key( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -375,12 +451,11 @@ async def test_user_dashboard_has_wrong_key( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_discovers_name_and_gets_key_from_dashboard( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test user step can discover the name and get the key from the dashboard.""" mock_client.device_info.side_effect = [ @@ -406,7 +481,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard( return_value=VALID_NOISE_PSK, ): result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) @@ -427,13 +502,12 @@ async def test_user_discovers_name_and_gets_key_from_dashboard( "dashboard_exception", [aiohttp.ClientError(), json.JSONDecodeError("test", "test", 0)], ) -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_discovers_name_and_gets_key_from_dashboard_fails( hass: HomeAssistant, dashboard_exception: Exception, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test user step can discover the name and get the key from the dashboard.""" mock_client.device_info.side_effect = [ @@ -459,7 +533,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails( side_effect=dashboard_exception, ): result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) @@ -467,6 +541,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -483,12 +558,11 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_discovers_name_and_dashboard_is_unavailable( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test user step can discover the name but the dashboard is unavailable.""" mock_client.device_info.side_effect = [ @@ -509,12 +583,12 @@ async def test_user_discovers_name_and_dashboard_is_unavailable( ) with patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_devices", side_effect=TimeoutError, ): await dashboard.async_get_dashboard(hass).async_refresh() result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) @@ -522,6 +596,7 @@ async def test_user_discovers_name_and_dashboard_is_unavailable( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -538,21 +613,22 @@ async def test_user_discovers_name_and_dashboard_is_unavailable( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_login_connection_error( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test user step with connection error on login attempt.""" mock_client.device_info.return_value = DeviceInfo(uses_password=True, name="test") result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} mock_client.connect.side_effect = APIConnectionError @@ -562,13 +638,28 @@ async def test_login_connection_error( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} assert result["errors"] == {"base": "connection_error"} + mock_client.connect.side_effect = None -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_initiation( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "good"} + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "test" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_DEVICE_NAME: "test", + CONF_PASSWORD: "good", + CONF_NOISE_PSK: "", + } + + +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_initiation(hass: HomeAssistant) -> None: """Test discovery importing works.""" service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), @@ -578,12 +669,18 @@ async def test_discovery_initiation( port=6053, properties={ "mac": "1122334455aa", + "friendly_name": "The Test", }, type="mock_type", ) flow = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) + assert get_flow_context(hass, flow) == { + "source": config_entries.SOURCE_ZEROCONF, + "title_placeholders": {"name": "The Test (test)"}, + "unique_id": "11:22:33:44:55:aa", + } result = await hass.config_entries.flow.async_configure( flow["flow_id"], user_input={} @@ -598,10 +695,8 @@ async def test_discovery_initiation( assert result["result"].unique_id == "11:22:33:44:55:aa" -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_no_mac( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_no_mac(hass: HomeAssistant) -> None: """Test discovery aborted if old ESPHome without mac in zeroconf.""" service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), @@ -613,15 +708,14 @@ async def test_discovery_no_mac( type="mock_type", ) flow = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) assert flow["type"] is FlowResultType.ABORT assert flow["reason"] == "mdns_missing_mac" -async def test_discovery_already_configured( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_already_configured(hass: HomeAssistant) -> None: """Test discovery aborts if already configured via hostname.""" entry = MockConfigEntry( domain=DOMAIN, @@ -641,16 +735,49 @@ async def test_discovery_already_configured( type="mock_type", ) result = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } + + +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_ignored(hass: HomeAssistant) -> None: + """Test discovery does not probe and ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "test8266.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + source=SOURCE_IGNORE, + ) + + entry.add_to_hass(hass) + + service_info = ZeroconfServiceInfo( + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], + hostname="test8266.local.", + name="mock_name", + port=6053, + properties={"mac": "1122334455aa"}, + type="mock_type", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" -async def test_discovery_duplicate_data( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_duplicate_data(hass: HomeAssistant) -> None: """Test discovery aborts if same mDNS packet arrives.""" service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), @@ -663,21 +790,21 @@ async def test_discovery_duplicate_data( ) result = await hass.config_entries.flow.async_init( - "esphome", data=service_info, context={"source": config_entries.SOURCE_ZEROCONF} + DOMAIN, data=service_info, context={"source": config_entries.SOURCE_ZEROCONF} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_confirm" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_init( - "esphome", data=service_info, context={"source": config_entries.SOURCE_ZEROCONF} + DOMAIN, data=service_info, context={"source": config_entries.SOURCE_ZEROCONF} ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_in_progress" -async def test_discovery_updates_unique_id( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_updates_unique_id(hass: HomeAssistant) -> None: """Test a duplicate discovery host aborts and updates existing entry.""" entry = MockConfigEntry( domain=DOMAIN, @@ -687,6 +814,44 @@ async def test_discovery_updates_unique_id( entry.add_to_hass(hass) + service_info = ZeroconfServiceInfo( + ip_address=ip_address("192.168.43.184"), + ip_addresses=[ip_address("192.168.43.184")], + hostname="test8266.local.", + name="mock_name", + port=6053, + properties={"address": "test8266.local", "mac": "1122334455aa"}, + type="mock_type", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } + + assert entry.data[CONF_HOST] == "192.168.43.184" + assert entry.unique_id == "11:22:33:44:55:aa" + + +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_abort_without_update_same_host_port( + hass: HomeAssistant, +) -> None: + """Test discovery aborts without update when hsot and port are the same.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + + entry.add_to_hass(hass) + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], @@ -697,24 +862,20 @@ async def test_discovery_updates_unique_id( type="mock_type", ) result = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - assert entry.unique_id == "11:22:33:44:55:aa" - -@pytest.mark.usefixtures("mock_zeroconf") -async def test_user_requires_psk( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") +async def test_user_requires_psk(hass: HomeAssistant, mock_client: APIClient) -> None: """Test user step with requiring encryption key.""" mock_client.device_info.side_effect = RequiresEncryptionAPIError result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) @@ -722,28 +883,52 @@ async def test_user_requires_psk( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" assert result["errors"] == {} + assert result["description_placeholders"] == {"name": "ESPHome"} assert len(mock_client.connect.mock_calls) == 2 assert len(mock_client.device_info.mock_calls) == 2 assert len(mock_client.disconnect.mock_calls) == 2 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: INVALID_NOISE_PSK} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encryption_key" + assert result["errors"] == {"base": "requires_encryption_key"} + assert result["description_placeholders"] == {"name": "ESPHome"} -@pytest.mark.usefixtures("mock_zeroconf") + mock_client.device_info.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + CONF_DEVICE_NAME: "test", + } + assert mock_client.noise_psk == VALID_NOISE_PSK + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_encryption_key_valid_psk( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test encryption key step with valid key.""" mock_client.device_info.side_effect = RequiresEncryptionAPIError result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "ESPHome"} mock_client.device_info = AsyncMock( return_value=DeviceInfo(uses_password=False, name="test") @@ -763,22 +948,23 @@ async def test_encryption_key_valid_psk( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_encryption_key_invalid_psk( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test encryption key step with invalid key.""" mock_client.device_info.side_effect = RequiresEncryptionAPIError result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "ESPHome"} mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError result = await hass.config_entries.flow.async_configure( @@ -788,37 +974,47 @@ async def test_encryption_key_invalid_psk( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" assert result["errors"] == {"base": "invalid_psk"} + assert result["description_placeholders"] == {"name": "ESPHome"} assert mock_client.noise_psk == INVALID_NOISE_PSK + mock_client.device_info.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) -@pytest.mark.usefixtures("mock_zeroconf") -async def test_reauth_initiation(hass: HomeAssistant, mock_client) -> None: - """Test reauth initiation shows form.""" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + CONF_DEVICE_NAME: "test", + } + assert mock_client.noise_psk == VALID_NOISE_PSK + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") +async def test_reauth_confirm_valid( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reauth initiation with valid PSK.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } - -@pytest.mark.usefixtures("mock_zeroconf") -async def test_reauth_confirm_valid( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: - """Test reauth initiation with valid PSK.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" ) - entry.add_to_hass(hass) - - result = await entry.start_reauth_flow(hass) - - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} ) @@ -828,12 +1024,53 @@ async def test_reauth_confirm_valid( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reauth_attempt_to_change_mac_aborts( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reauth initiation with valid PSK attempting to change mac. + + This can happen if reauth starts, but they don't finish it before + a new device takes the place of the old one at the same IP. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_unique_id_changed" + assert CONF_NOISE_PSK not in entry.data + assert result["description_placeholders"] == { + "expected_mac": "11:22:33:44:55:aa", + "host": "127.0.0.1", + "name": "test", + "unexpected_device_name": "test", + "unexpected_mac": "11:22:33:44:55:bb", + } + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_fixed_via_dashboard( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test reauth fixed automatically via dashboard.""" @@ -845,10 +1082,13 @@ async def test_reauth_fixed_via_dashboard( CONF_PASSWORD: "", CONF_DEVICE_NAME: "test", }, + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) mock_dashboard["configured"].append( { @@ -872,18 +1112,17 @@ async def test_reauth_fixed_via_dashboard( assert len(mock_get_encryption_key.mock_calls) == 1 -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], mock_config_entry: MockConfigEntry, - mock_setup_entry: None, ) -> None: """Test reauth fixed automatically via dashboard with password removed.""" mock_client.device_info.side_effect = ( InvalidAuthAPIError, - DeviceInfo(uses_password=False, name="test"), + DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"), ) mock_dashboard["configured"].append( @@ -909,15 +1148,16 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( assert len(mock_get_encryption_key.mock_calls) == 1 +@pytest.mark.usefixtures("mock_dashboard", "mock_setup_entry", "mock_zeroconf") async def test_reauth_fixed_via_remove_password( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_config_entry: MockConfigEntry, - mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test reauth fixed automatically by seeing password removed.""" - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) result = await mock_config_entry.start_reauth_flow(hass) @@ -926,12 +1166,11 @@ async def test_reauth_fixed_via_remove_password( assert mock_config_entry.data[CONF_PASSWORD] == "" -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_fixed_via_dashboard_at_confirm( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test reauth fixed automatically via dashboard at confirm step.""" @@ -943,15 +1182,21 @@ async def test_reauth_fixed_via_dashboard_at_confirm( CONF_PASSWORD: "", CONF_DEVICE_NAME: "test", }, + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM, result assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } mock_dashboard["configured"].append( { @@ -976,14 +1221,15 @@ async def test_reauth_fixed_via_dashboard_at_confirm( assert len(mock_get_encryption_key.mock_calls) == 1 -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_confirm_invalid( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test reauth initiation with invalid PSK.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) @@ -996,11 +1242,16 @@ async def test_reauth_confirm_invalid( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } assert result["errors"] assert result["errors"]["base"] == "invalid_psk" mock_client.device_info = AsyncMock( - return_value=DeviceInfo(uses_password=False, name="test") + return_value=DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1011,15 +1262,15 @@ async def test_reauth_confirm_invalid( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_confirm_invalid_with_unique_id( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test reauth initiation with invalid PSK.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, - unique_id="test", + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) @@ -1032,11 +1283,16 @@ async def test_reauth_confirm_invalid_with_unique_id( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } assert result["errors"] assert result["errors"]["base"] == "invalid_psk" mock_client.device_info = AsyncMock( - return_value=DeviceInfo(uses_password=False, name="test") + return_value=DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1047,10 +1303,8 @@ async def test_reauth_confirm_invalid_with_unique_id( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") -async def test_reauth_encryption_key_removed( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_reauth_encryption_key_removed(hass: HomeAssistant) -> None: """Test reauth when the encryption key was removed.""" entry = MockConfigEntry( domain=DOMAIN, @@ -1060,13 +1314,16 @@ async def test_reauth_encryption_key_removed( CONF_PASSWORD: "", CONF_NOISE_PSK: VALID_NOISE_PSK, }, - unique_id="test", + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_encryption_removed_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -1077,8 +1334,9 @@ async def test_reauth_encryption_key_removed( assert entry.data[CONF_NOISE_PSK] == "" +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_discovery_dhcp_updates_host( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test dhcp discovery updates host and aborts.""" entry = MockConfigEntry( @@ -1087,6 +1345,9 @@ async def test_discovery_dhcp_updates_host( unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(name="test8266", mac_address="1122334455aa") + ) service_info = DhcpServiceInfo( ip="192.168.43.184", @@ -1094,17 +1355,130 @@ async def test_discovery_dhcp_updates_host( macaddress="1122334455aa", ) result = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=service_info ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } assert entry.data[CONF_HOST] == "192.168.43.184" +@pytest.mark.usefixtures("mock_setup_entry") +async def test_discovery_dhcp_does_not_update_host_wrong_mac( + hass: HomeAssistant, + mock_client: APIClient, +) -> None: + """Test dhcp discovery does not update the host if the mac is wrong.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(name="test8266", mac_address="1122334455ff") + ) + + service_info = DhcpServiceInfo( + ip="192.168.43.184", + hostname="test8266", + macaddress="1122334455aa", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_detailed" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } + + # Mac was wrong, should not update + assert entry.data[CONF_HOST] == "192.168.43.183" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_discovery_dhcp_does_not_update_host_wrong_mac_bad_key( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test dhcp discovery does not update the host if the mac is wrong.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError( + "Wrong key", "test8266", "1122334455cc" + ) + service_info = DhcpServiceInfo( + ip="192.168.43.184", + hostname="test8266", + macaddress="1122334455aa", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_detailed" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } + + # Mac was wrong, should not update + assert entry.data[CONF_HOST] == "192.168.43.183" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_discovery_dhcp_does_not_update_host_missing_mac_bad_key( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test dhcp discovery does not update the host if the mac is missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError( + "Wrong key", "test8266", None + ) + service_info = DhcpServiceInfo( + ip="192.168.43.184", + hostname="test8266", + macaddress="1122334455aa", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_detailed" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } + + # Mac was missing, should not update + assert entry.data[CONF_HOST] == "192.168.43.183" + + +@pytest.mark.usefixtures("mock_setup_entry") async def test_discovery_dhcp_no_changes( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test dhcp discovery updates host and aborts.""" entry = MockConfigEntry( @@ -1121,7 +1495,7 @@ async def test_discovery_dhcp_no_changes( macaddress="000000000000", ) result = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=service_info ) assert result["type"] is FlowResultType.ABORT @@ -1130,12 +1504,11 @@ async def test_discovery_dhcp_no_changes( assert entry.data[CONF_HOST] == "192.168.43.183" -async def test_discovery_hassio( - hass: HomeAssistant, mock_dashboard: dict[str, Any] -) -> None: +@pytest.mark.usefixtures("mock_dashboard") +async def test_discovery_hassio(hass: HomeAssistant) -> None: """Test dashboard discovery.""" result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, data=HassioServiceInfo( config={ "host": "mock-esphome", @@ -1156,12 +1529,11 @@ async def test_discovery_hassio( assert dash.addon_slug == "mock-slug" -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_zeroconf_encryption_key_via_dashboard( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test encryption key retrieved from dashboard.""" service_info = ZeroconfServiceInfo( @@ -1176,11 +1548,12 @@ async def test_zeroconf_encryption_key_via_dashboard( type="mock_type", ) flow = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) assert flow["type"] is FlowResultType.FORM assert flow["step_id"] == "discovery_confirm" + assert flow["description_placeholders"] == {"name": "test8266"} mock_dashboard["configured"].append( { @@ -1222,12 +1595,11 @@ async def test_zeroconf_encryption_key_via_dashboard( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test encryption key retrieved from dashboard with api_encryption property set.""" service_info = ZeroconfServiceInfo( @@ -1243,11 +1615,12 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( type="mock_type", ) flow = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) assert flow["type"] is FlowResultType.FORM assert flow["step_id"] == "discovery_confirm" + assert flow["description_placeholders"] == {"name": "test8266"} mock_dashboard["configured"].append( { @@ -1288,12 +1661,10 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_dashboard", "mock_setup_entry", "mock_zeroconf") async def test_zeroconf_no_encryption_key_via_dashboard( hass: HomeAssistant, - mock_client, - mock_dashboard: dict[str, Any], - mock_setup_entry: None, + mock_client: APIClient, ) -> None: """Test encryption key not retrieved from dashboard.""" service_info = ZeroconfServiceInfo( @@ -1308,11 +1679,12 @@ async def test_zeroconf_no_encryption_key_via_dashboard( type="mock_type", ) flow = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) assert flow["type"] is FlowResultType.FORM assert flow["step_id"] == "discovery_confirm" + assert flow["description_placeholders"] == {"name": "test8266"} await dashboard.async_get_dashboard(hass).async_refresh() @@ -1324,12 +1696,28 @@ async def test_zeroconf_no_encryption_key_via_dashboard( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test8266"} + + mock_client.device_info.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "192.168.43.183", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + CONF_DEVICE_NAME: "test", + } + assert mock_client.noise_psk == VALID_NOISE_PSK async def test_option_flow_allow_service_calls( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test config flow options for allow service calls.""" entry = await mock_generic_device_entry( @@ -1374,7 +1762,7 @@ async def test_option_flow_allow_service_calls( async def test_option_flow_subscribe_logs( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test config flow options with subscribe logs.""" entry = await mock_generic_device_entry( @@ -1410,11 +1798,10 @@ async def test_option_flow_subscribe_logs( assert len(mock_reload.mock_calls) == 1 -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_discovers_name_no_dashboard( hass: HomeAssistant, - mock_client, - mock_setup_entry: None, + mock_client: APIClient, ) -> None: """Test user step can discover the name and the there is not dashboard.""" mock_client.device_info.side_effect = [ @@ -1428,7 +1815,7 @@ async def test_user_discovers_name_no_dashboard( ] result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) @@ -1436,6 +1823,7 @@ async def test_user_discovers_name_no_dashboard( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1452,7 +1840,9 @@ async def test_user_discovers_name_no_dashboard( assert mock_client.noise_psk == VALID_NOISE_PSK -async def mqtt_discovery_test_abort(hass: HomeAssistant, payload: str, reason: str): +async def mqtt_discovery_test_abort( + hass: HomeAssistant, payload: str, reason: str +) -> None: """Test discovery aborted.""" service_info = MqttServiceInfo( topic="esphome/discover/test", @@ -1463,50 +1853,40 @@ async def mqtt_discovery_test_abort(hass: HomeAssistant, payload: str, reason: s timestamp=None, ) flow = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_MQTT}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_MQTT}, data=service_info ) assert flow["type"] is FlowResultType.ABORT assert flow["reason"] == reason -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_mqtt_no_mac( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_mqtt_no_mac(hass: HomeAssistant) -> None: """Test discovery aborted if mac is missing in MQTT payload.""" await mqtt_discovery_test_abort(hass, "{}", "mqtt_missing_mac") -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_mqtt_empty_payload( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_mqtt_empty_payload(hass: HomeAssistant) -> None: """Test discovery aborted if MQTT payload is empty.""" await mqtt_discovery_test_abort(hass, "", "mqtt_missing_payload") -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_mqtt_no_api( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_mqtt_no_api(hass: HomeAssistant) -> None: """Test discovery aborted if api/port is missing in MQTT payload.""" await mqtt_discovery_test_abort(hass, '{"mac":"abcdef123456"}', "mqtt_missing_api") -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_mqtt_no_ip( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_mqtt_no_ip(hass: HomeAssistant) -> None: """Test discovery aborted if ip is missing in MQTT payload.""" await mqtt_discovery_test_abort( hass, '{"mac":"abcdef123456","port":6053}', "mqtt_missing_ip" ) -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_mqtt_initiation( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_mqtt_initiation(hass: HomeAssistant) -> None: """Test discovery importing works.""" service_info = MqttServiceInfo( topic="esphome/discover/test", @@ -1517,7 +1897,7 @@ async def test_discovery_mqtt_initiation( timestamp=None, ) flow = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_MQTT}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_MQTT}, data=service_info ) result = await hass.config_entries.flow.async_configure( @@ -1531,3 +1911,468 @@ async def test_discovery_mqtt_initiation( assert result["result"] assert result["result"].unique_id == "11:22:33:44:55:aa" + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") +async def test_user_flow_name_conflict_migrate( + hass: HomeAssistant, + mock_client: APIClient, +) -> None: + """Test handle migration on name conflict.""" + existing_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE_NAME: "test"}, + unique_id="11:22:33:44:55:cc", + ) + existing_entry.add_to_hass(hass) + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + uses_password=False, + name="test", + mac_address="11:22:33:44:55:AA", + ) + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "name_conflict" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "name_conflict_migrate"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "name_conflict_migrated" + assert result["description_placeholders"] == { + "existing_mac": "11:22:33:44:55:cc", + "mac": "11:22:33:44:55:aa", + "name": "test", + } + assert existing_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + CONF_DEVICE_NAME: "test", + } + assert existing_entry.unique_id == "11:22:33:44:55:aa" + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") +async def test_user_flow_name_conflict_overwrite( + hass: HomeAssistant, + mock_client: APIClient, +) -> None: + """Test handle overwrite on name conflict.""" + existing_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE_NAME: "test"}, + unique_id="11:22:33:44:55:cc", + ) + existing_entry.add_to_hass(hass) + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + uses_password=False, + name="test", + mac_address="11:22:33:44:55:AA", + ) + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "name_conflict" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "name_conflict_overwrite"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + CONF_DEVICE_NAME: "test", + } + assert result["context"]["unique_id"] == "11:22:33:44:55:aa" + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_success_with_same_ip_new_name( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation with same ip and new name.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="other", mac_address="11:22:33:44:55:aa" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "127.0.0.1" + assert entry.data[CONF_DEVICE_NAME] == "other" + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_success_with_new_ip_new_name( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation with new ip and new name.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="other", mac_address="11:22:33:44:55:aa" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "127.0.0.2" + assert entry.data[CONF_DEVICE_NAME] == "other" + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_success_with_new_ip_same_name( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation with new ip and same name.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + CONF_NOISE_PSK: VALID_NOISE_PSK, + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "127.0.0.1" + assert entry.data[CONF_DEVICE_NAME] == "test" + assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_success_noise_psk_changes( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation with new ip and new noise psk.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + CONF_NOISE_PSK: VALID_NOISE_PSK, + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + mock_client.device_info.side_effect = [ + RequiresEncryptionAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test"), + DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"), + ] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "Mock Title (test)"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "Mock Title (test)"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "127.0.0.1" + assert entry.data[CONF_DEVICE_NAME] == "test" + assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_name_conflict_with_existing_entry( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig with a name conflict with an existing entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + entry2 = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.2", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "other", + }, + unique_id="11:22:33:44:55:bb", + ) + entry2.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="other", mac_address="11:22:33:44:55:aa" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.3", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_name_conflict" + assert result["description_placeholders"] == { + "existing_title": "Mock Title", + "expected_mac": "11:22:33:44:55:aa", + "host": "127.0.0.3", + "name": "test", + } + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_attempt_to_change_mac_aborts( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation with valid PSK attempting to change mac.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="other", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_unique_id_changed" + assert CONF_NOISE_PSK not in entry.data + assert result["description_placeholders"] == { + "expected_mac": "11:22:33:44:55:aa", + "host": "127.0.0.2", + "name": "test", + "unexpected_device_name": "other", + "unexpected_mac": "11:22:33:44:55:bb", + } + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_mac_used_by_other_entry( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig when there is another entry for the mac.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + entry2 = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.2", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test4", + }, + unique_id="11:22:33:44:55:bb", + ) + entry2.add_to_hass(hass) + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_already_configured" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "test4", + "mac": "11:22:33:44:55:bb", + } + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_name_conflict_migrate( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation when device has been replaced.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "name_conflict" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "name_conflict_migrate"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "name_conflict_migrated" + + assert entry.data == { + CONF_HOST: "127.0.0.2", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + CONF_DEVICE_NAME: "test", + } + assert entry.unique_id == "11:22:33:44:55:bb" + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_name_conflict_overwrite( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation when device has been replaced.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "name_conflict" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "name_conflict_overwrite"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert result["data"] == { + CONF_HOST: "127.0.0.2", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + CONF_DEVICE_NAME: "test", + } + assert result["context"]["unique_id"] == "11:22:33:44:55:bb" + assert ( + hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, "11:22:33:44:55:aa" + ) + is None + ) diff --git a/tests/components/esphome/test_cover.py b/tests/components/esphome/test_cover.py index 4cfe91c6dea..2ea789e9cc1 100644 --- a/tests/components/esphome/test_cover.py +++ b/tests/components/esphome/test_cover.py @@ -1,6 +1,5 @@ """Test ESPHome covers.""" -from collections.abc import Awaitable, Callable from unittest.mock import call from aioesphomeapi import ( @@ -8,9 +7,6 @@ from aioesphomeapi import ( CoverInfo, CoverOperation, CoverState as ESPHomeCoverState, - EntityInfo, - EntityState, - UserService, ) from homeassistant.components.cover import ( @@ -31,16 +27,13 @@ from homeassistant.components.cover import ( from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from .conftest import MockESPHomeDevice +from .conftest import MockESPHomeDeviceType async def test_cover_entity( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a generic cover entity.""" entity_info = [ @@ -168,10 +161,7 @@ async def test_cover_entity( async def test_cover_entity_without_position( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a generic cover entity without position, tilt, or stop.""" entity_info = [ diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index 1641804e458..340a10a86d1 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -3,22 +3,25 @@ from typing import Any from unittest.mock import patch -from aioesphomeapi import DeviceInfo, InvalidAuthAPIError +from aioesphomeapi import APIClient, DeviceInfo, InvalidAuthAPIError +import pytest -from homeassistant.components.esphome import CONF_NOISE_PSK, coordinator, dashboard +from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN, dashboard from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.setup import async_setup_component from . import VALID_NOISE_PSK +from .common import MockDashboardRefresh +from .conftest import MockESPHomeDeviceType from tests.common import MockConfigEntry +@pytest.mark.usefixtures("init_integration", "mock_dashboard") async def test_dashboard_storage( hass: HomeAssistant, - init_integration, - mock_dashboard: dict[str, Any], hass_storage: dict[str, Any], ) -> None: """Test dashboard storage.""" @@ -33,7 +36,6 @@ async def test_dashboard_storage( async def test_restore_dashboard_storage( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, hass_storage: dict[str, Any], ) -> None: """Restore dashboard url and slug from storage.""" @@ -46,14 +48,13 @@ async def test_restore_dashboard_storage( with patch.object( dashboard, "async_get_or_create_dashboard_manager" ) as mock_get_or_create: - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert mock_get_or_create.call_count == 1 async def test_restore_dashboard_storage_end_to_end( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, hass_storage: dict[str, Any], ) -> None: """Restore dashboard url and slug from storage.""" @@ -63,28 +64,62 @@ async def test_restore_dashboard_storage_end_to_end( "key": dashboard.STORAGE_KEY, "data": {"info": {"addon_slug": "test-slug", "host": "new-host", "port": 6052}}, } - with patch( - "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI" - ) as mock_dashboard_api: - await hass.config_entries.async_setup(mock_config_entry.entry_id) + with ( + patch( + "homeassistant.components.esphome.dashboard.is_hassio", return_value=False + ), + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI" + ) as mock_dashboard_api, + ): + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_dashboard_api.mock_calls[0][1][0] == "http://new-host:6052" +@pytest.mark.usefixtures("hassio_stubs") +async def test_restore_dashboard_storage_skipped_if_addon_uninstalled( + hass: HomeAssistant, + hass_storage: dict[str, Any], + caplog: pytest.LogCaptureFixture, +) -> None: + """Restore dashboard restore is skipped if the addon is uninstalled.""" + hass_storage[dashboard.STORAGE_KEY] = { + "version": dashboard.STORAGE_VERSION, + "minor_version": dashboard.STORAGE_VERSION, + "key": dashboard.STORAGE_KEY, + "data": {"info": {"addon_slug": "test-slug", "host": "new-host", "port": 6052}}, + } + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI" + ) as mock_dashboard_api, + patch( + "homeassistant.components.esphome.dashboard.is_hassio", return_value=True + ), + patch( + "homeassistant.components.hassio.get_addons_info", + return_value={}, + ), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert "test-slug is no longer installed" in caplog.text + assert not mock_dashboard_api.called + + async def test_setup_dashboard_fails( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, hass_storage: dict[str, Any], ) -> None: """Test that nothing is stored on failed dashboard setup when there was no dashboard before.""" - with patch.object( - coordinator.ESPHomeDashboardAPI, "get_devices", side_effect=TimeoutError + with patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_devices", + side_effect=TimeoutError, ) as mock_get_devices: - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() await dashboard.async_set_dashboard_info(hass, "test-slug", "test-host", 6052) - assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_get_devices.call_count == 1 # The dashboard addon might recover later so we still @@ -98,8 +133,8 @@ async def test_setup_dashboard_fails_when_already_setup( hass_storage: dict[str, Any], ) -> None: """Test failed dashboard setup still reloads entries if one existed before.""" - with patch.object( - coordinator.ESPHomeDashboardAPI, "get_devices" + with patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_devices" ) as mock_get_devices: await dashboard.async_set_dashboard_info( hass, "test-slug", "working-host", 6052 @@ -113,8 +148,9 @@ async def test_setup_dashboard_fails_when_already_setup( await hass.async_block_till_done() with ( - patch.object( - coordinator.ESPHomeDashboardAPI, "get_devices", side_effect=TimeoutError + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_devices", + side_effect=TimeoutError, ) as mock_get_devices, patch( "homeassistant.components.esphome.async_setup_entry", return_value=True @@ -130,8 +166,9 @@ async def test_setup_dashboard_fails_when_already_setup( assert len(mock_setup.mock_calls) == 1 +@pytest.mark.usefixtures("mock_dashboard") async def test_new_info_reload_config_entries( - hass: HomeAssistant, init_integration, mock_dashboard + hass: HomeAssistant, init_integration: MockConfigEntry ) -> None: """Test config entries are reloaded when new info is set.""" assert init_integration.state is ConfigEntryState.LOADED @@ -150,12 +187,15 @@ async def test_new_info_reload_config_entries( async def test_new_dashboard_fix_reauth( - hass: HomeAssistant, mock_client, mock_config_entry: MockConfigEntry, mock_dashboard + hass: HomeAssistant, + mock_client: APIClient, + mock_config_entry: MockConfigEntry, + mock_dashboard: dict[str, Any], ) -> None: """Test config entries waiting for reauth are triggered.""" mock_client.device_info.side_effect = ( InvalidAuthAPIError, - DeviceInfo(uses_password=False, name="test"), + DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:AA"), ) with patch( @@ -174,7 +214,7 @@ async def test_new_dashboard_fix_reauth( } ) - await dashboard.async_get_dashboard(hass).async_refresh() + await MockDashboardRefresh(hass).async_refresh() with ( patch( @@ -194,15 +234,29 @@ async def test_new_dashboard_fix_reauth( async def test_dashboard_supports_update( - hass: HomeAssistant, mock_dashboard: dict[str, Any] + hass: HomeAssistant, + mock_dashboard: dict[str, Any], + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test dashboard supports update.""" dash = dashboard.async_get_dashboard(hass) + mock_refresh = MockDashboardRefresh(hass) + + entity_info = [] + states = [] + user_service = [] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) # No data assert not dash.supports_update - await dash.async_refresh() + await mock_refresh.async_refresh() assert dash.supports_update is None # supported version @@ -213,12 +267,44 @@ async def test_dashboard_supports_update( "current_version": "2023.2.0-dev", } ) - await dash.async_refresh() + + await mock_refresh.async_refresh() assert dash.supports_update is True - # unsupported version - dash.supports_update = None - mock_dashboard["configured"][0]["current_version"] = "2023.1.0" - await dash.async_refresh() +async def test_dashboard_unsupported_version( + hass: HomeAssistant, + mock_dashboard: dict[str, Any], + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test dashboard with unsupported version.""" + dash = dashboard.async_get_dashboard(hass) + mock_refresh = MockDashboardRefresh(hass) + + entity_info = [] + states = [] + user_service = [] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + + # No data + assert not dash.supports_update + + await mock_refresh.async_refresh() + assert dash.supports_update is None + + # unsupported version + mock_dashboard["configured"].append( + { + "name": "test", + "configuration": "test.yaml", + "current_version": "2023.1.0", + } + ) + await mock_refresh.async_refresh() assert dash.supports_update is False diff --git a/tests/components/esphome/test_date.py b/tests/components/esphome/test_date.py index 2deb92775fb..4bf291c50f5 100644 --- a/tests/components/esphome/test_date.py +++ b/tests/components/esphome/test_date.py @@ -12,11 +12,13 @@ from homeassistant.components.date import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + async def test_generic_date_entity( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic date entity.""" entity_info = [ @@ -52,7 +54,7 @@ async def test_generic_date_entity( async def test_generic_date_missing_state( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic date entity with missing state.""" entity_info = [ diff --git a/tests/components/esphome/test_datetime.py b/tests/components/esphome/test_datetime.py index 3bdc196de95..1ccb101f581 100644 --- a/tests/components/esphome/test_datetime.py +++ b/tests/components/esphome/test_datetime.py @@ -12,11 +12,13 @@ from homeassistant.components.datetime import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + async def test_generic_datetime_entity( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic datetime entity.""" entity_info = [ @@ -55,7 +57,7 @@ async def test_generic_datetime_entity( async def test_generic_datetime_missing_state( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic datetime entity with missing state.""" entity_info = [ diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 2d64170bc97..250cc8dbc49 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -3,6 +3,7 @@ from typing import Any from unittest.mock import ANY +from aioesphomeapi import APIClient import pytest from syrupy import SnapshotAssertion from syrupy.filters import props @@ -10,7 +11,8 @@ from syrupy.filters import props from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant -from .conftest import MockESPHomeDevice +from .common import MockDashboardRefresh +from .conftest import MockESPHomeDevice, MockESPHomeDeviceType from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -31,6 +33,37 @@ async def test_diagnostics( assert result == snapshot(exclude=props("created_at", "modified_at")) +@pytest.mark.usefixtures("enable_bluetooth") +async def test_diagnostics_with_dashboard_data( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], + mock_client: APIClient, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry with dashboard data.""" + mock_dashboard["configured"].append( + { + "name": "test", + "configuration": "test.yaml", + "current_version": "2023.1.0", + } + ) + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + await MockDashboardRefresh(hass).async_refresh() + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_device.entry + ) + + assert result == snapshot(exclude=props("entry_id", "created_at", "modified_at")) + + async def test_diagnostics_with_bluetooth( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -43,6 +76,9 @@ async def test_diagnostics_with_bluetooth( entry = mock_bluetooth_entry_with_raw_adv.entry result = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert result == { + "dashboard": { + "configured": False, + }, "bluetooth": { "available": True, "connections_free": 0, diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index 977ec50ab30..ee6e6b6785f 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -1,7 +1,6 @@ """Test ESPHome binary sensors.""" import asyncio -from collections.abc import Awaitable, Callable from typing import Any from unittest.mock import AsyncMock @@ -9,25 +8,27 @@ from aioesphomeapi import ( APIClient, BinarySensorInfo, BinarySensorState, - EntityInfo, - EntityState, SensorInfo, SensorState, - UserService, + build_unique_id, ) +import pytest +from homeassistant.components.esphome import DOMAIN from homeassistant.const import ( + ATTR_FRIENDLY_NAME, ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + Platform, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.event import async_track_state_change_event -from .conftest import MockESPHomeDevice +from .conftest import MockESPHomeDevice, MockESPHomeDeviceType async def test_entities_removed( @@ -35,10 +36,7 @@ async def test_entities_removed( entity_registry: er.EntityRegistry, mock_client: APIClient, hass_storage: dict[str, Any], - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test entities are removed when static info changes.""" entity_info = [ @@ -130,10 +128,7 @@ async def test_entities_removed_after_reload( entity_registry: er.EntityRegistry, mock_client: APIClient, hass_storage: dict[str, Any], - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test entities and their registry entry are removed when static info changes after a reload.""" entity_info = [ @@ -220,9 +215,6 @@ async def test_entities_removed_after_reload( unique_id="my_binary_sensor", ), ] - states = [ - BinarySensorState(key=1, state=True, missing_state=False), - ] mock_device.client.list_entities_services = AsyncMock( return_value=(entity_info, user_service) ) @@ -265,10 +257,7 @@ async def test_entities_for_entire_platform_removed( entity_registry: er.EntityRegistry, mock_client: APIClient, hass_storage: dict[str, Any], - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test removing all entities for a specific platform when static info changes.""" entity_info = [ @@ -333,10 +322,7 @@ async def test_entities_for_entire_platform_removed( async def test_entity_info_object_ids( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test how object ids affect entity id.""" entity_info = [ @@ -363,10 +349,7 @@ async def test_deep_sleep_device( hass: HomeAssistant, mock_client: APIClient, hass_storage: dict[str, Any], - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a deep sleep device.""" entity_info = [ @@ -474,10 +457,7 @@ async def test_esphome_device_without_friendly_name( hass: HomeAssistant, mock_client: APIClient, hass_storage: dict[str, Any], - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device without friendly_name set.""" entity_info = [ @@ -500,6 +480,188 @@ async def test_esphome_device_without_friendly_name( states=states, device_info={"friendly_name": None}, ) - state = hass.states.get("binary_sensor.my_binary_sensor") + state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == STATE_ON + + +async def test_entity_without_name_device_with_friendly_name( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test name and entity_id for a device a friendly name and an entity without a name.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="", + unique_id="my_binary_sensor", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + user_service = [] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"friendly_name": "The Best Mixer", "name": "mixer"}, + ) + state = hass.states.get("binary_sensor.mixer") + assert state is not None + assert state.state == STATE_ON + # Make sure we have set the name to `None` as otherwise + # the friendly_name will be "The Best Mixer " + assert state.attributes[ATTR_FRIENDLY_NAME] == "The Best Mixer" + + +@pytest.mark.usefixtures("hass_storage") +async def test_entity_id_preserved_on_upgrade( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity_id is preserved on upgrade.""" + entity_info = [ + BinarySensorInfo( + object_id="my", + key=1, + name="my", + unique_id="binary_sensor_my", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + user_service = [] + assert ( + build_unique_id("11:22:33:44:55:AA", entity_info[0]) + == "11:22:33:44:55:AA-binary_sensor-my" + ) + + entry = entity_registry.async_get_or_create( + Platform.BINARY_SENSOR, + DOMAIN, + "11:22:33:44:55:AA-binary_sensor-my", + suggested_object_id="should_not_change", + ) + assert entry.entity_id == "binary_sensor.should_not_change" + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"friendly_name": "The Best Mixer", "name": "mixer"}, + ) + state = hass.states.get("binary_sensor.should_not_change") + assert state is not None + + +@pytest.mark.usefixtures("hass_storage") +async def test_entity_id_preserved_on_upgrade_old_format_entity_id( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity_id is preserved on upgrade from old format.""" + entity_info = [ + BinarySensorInfo( + object_id="my", + key=1, + name="my", + unique_id="binary_sensor_my", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + user_service = [] + assert ( + build_unique_id("11:22:33:44:55:AA", entity_info[0]) + == "11:22:33:44:55:AA-binary_sensor-my" + ) + + entry = entity_registry.async_get_or_create( + Platform.BINARY_SENSOR, + DOMAIN, + "11:22:33:44:55:AA-binary_sensor-my", + suggested_object_id="my", + ) + assert entry.entity_id == "binary_sensor.my" + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"name": "mixer"}, + ) + state = hass.states.get("binary_sensor.my") + assert state is not None + + +async def test_entity_id_preserved_on_upgrade_when_in_storage( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], + mock_esphome_device: MockESPHomeDeviceType, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity_id is preserved on upgrade with user defined entity_id.""" + entity_info = [ + BinarySensorInfo( + object_id="my", + key=1, + name="my", + unique_id="binary_sensor_my", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + user_service = [] + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"friendly_name": "The Best Mixer", "name": "mixer"}, + ) + state = hass.states.get("binary_sensor.mixer_my") + assert state is not None + # now rename the entity + ent_reg_entry = entity_registry.async_get_or_create( + Platform.BINARY_SENSOR, + DOMAIN, + "11:22:33:44:55:AA-binary_sensor-my", + ) + entity_registry.async_update_entity( + ent_reg_entry.entity_id, + new_entity_id="binary_sensor.user_named", + ) + await hass.config_entries.async_unload(device.entry.entry_id) + await hass.async_block_till_done() + entry = device.entry + entry_id = entry.entry_id + storage_key = f"esphome.{entry_id}" + assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1 + binary_sensor_data: dict[str, Any] = hass_storage[storage_key]["data"][ + "binary_sensor" + ][0] + assert binary_sensor_data["name"] == "my" + assert binary_sensor_data["object_id"] == "my" + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + entry=entry, + device_info={"friendly_name": "The Best Mixer", "name": "mixer"}, + ) + state = hass.states.get("binary_sensor.user_named") + assert state is not None diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py index a8535c38224..886e5317462 100644 --- a/tests/components/esphome/test_entry_data.py +++ b/tests/components/esphome/test_entry_data.py @@ -7,15 +7,19 @@ from aioesphomeapi import ( SensorState, ) +from homeassistant.components.esphome import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import MockGenericDeviceEntryType + async def test_migrate_entity_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic sensor entity unique id migration.""" entity_registry.async_get_or_create( @@ -58,19 +62,19 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test unique id migration prefers the original entity on downgrade upgrade.""" entity_registry.async_get_or_create( - "sensor", - "esphome", + SENSOR_DOMAIN, + DOMAIN, "my_sensor", suggested_object_id="old_sensor", disabled_by=None, ) entity_registry.async_get_or_create( - "sensor", - "esphome", + SENSOR_DOMAIN, + DOMAIN, "11:22:33:44:55:AA-sensor-mysensor", suggested_object_id="new_sensor", disabled_by=None, @@ -103,7 +107,7 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( # entity that was only created on downgrade and they keep # the original one. assert ( - entity_registry.async_get_entity_id("sensor", "esphome", "my_sensor") + entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, "my_sensor") is not None ) # Note that ESPHome includes the EntityInfo type in the unique id diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py index 064b37b1ec1..a56ec1caeba 100644 --- a/tests/components/esphome/test_fan.py +++ b/tests/components/esphome/test_fan.py @@ -30,9 +30,13 @@ from homeassistant.components.fan import ( from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + async def test_fan_entity_with_all_features_old_api( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic fan entity that uses the old api and has all features.""" entity_info = [ @@ -132,7 +136,9 @@ async def test_fan_entity_with_all_features_old_api( async def test_fan_entity_with_all_features_new_api( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic fan entity that uses the new api and has all features.""" mock_client.api_version = APIVersion(1, 4) @@ -284,7 +290,9 @@ async def test_fan_entity_with_all_features_new_api( async def test_fan_entity_with_no_features_new_api( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic fan entity that uses the new api and has no features.""" mock_client.api_version = APIVersion(1, 4) diff --git a/tests/components/esphome/test_init.py b/tests/components/esphome/test_init.py index 9e4c9709e7d..7473734ff3e 100644 --- a/tests/components/esphome/test_init.py +++ b/tests/components/esphome/test_init.py @@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -@pytest.mark.usefixtures("mock_zeroconf") -async def test_delete_entry(hass: HomeAssistant, mock_client) -> None: - """Test we can delete an entry with error.""" +@pytest.mark.usefixtures("mock_client", "mock_zeroconf") +async def test_delete_entry(hass: HomeAssistant) -> None: + """Test we can delete an entry without error.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index 8e4f37079d1..d3302cab75c 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -38,9 +38,15 @@ from homeassistant.components.light import ( from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + +LIGHT_COLOR_CAPABILITY_UNKNOWN = 1 << 8 # 256 + async def test_light_on_off( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity that only supports on/off.""" mock_client.api_version = APIVersion(1, 7) @@ -80,7 +86,9 @@ async def test_light_on_off( async def test_light_brightness( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity that only supports brightness.""" mock_client.api_version = APIVersion(1, 7) @@ -196,7 +204,9 @@ async def test_light_brightness( async def test_light_brightness_on_off( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity that only supports brightness.""" mock_client.api_version = APIVersion(1, 7) @@ -264,7 +274,9 @@ async def test_light_brightness_on_off( async def test_light_legacy_white_converted_to_brightness( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity that only supports legacy white.""" mock_client.api_version = APIVersion(1, 7) @@ -316,7 +328,9 @@ async def test_light_legacy_white_converted_to_brightness( async def test_light_legacy_white_with_rgb( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity with rgb and white.""" mock_client.api_version = APIVersion(1, 7) @@ -378,7 +392,9 @@ async def test_light_legacy_white_with_rgb( async def test_light_brightness_on_off_with_unknown_color_mode( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity that only supports brightness along with an unknown color mode.""" mock_client.api_version = APIVersion(1, 7) @@ -391,7 +407,9 @@ async def test_light_brightness_on_off_with_unknown_color_mode( min_mireds=153, max_mireds=400, supported_color_modes=[ - LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS | 1 << 8 + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LIGHT_COLOR_CAPABILITY_UNKNOWN ], ) ] @@ -420,7 +438,7 @@ async def test_light_brightness_on_off_with_unknown_color_mode( state=True, color_mode=LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS - | 1 << 8, + | LIGHT_COLOR_CAPABILITY_UNKNOWN, ) ] ) @@ -439,7 +457,7 @@ async def test_light_brightness_on_off_with_unknown_color_mode( state=True, color_mode=LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS - | 1 << 8, + | LIGHT_COLOR_CAPABILITY_UNKNOWN, brightness=pytest.approx(0.4980392156862745), ) ] @@ -448,7 +466,9 @@ async def test_light_brightness_on_off_with_unknown_color_mode( async def test_light_on_and_brightness( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity that supports on and on and brightness.""" mock_client.api_version = APIVersion(1, 7) @@ -491,7 +511,9 @@ async def test_light_on_and_brightness( async def test_rgb_color_temp_light( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light that supports color temp and RGB.""" color_modes = [ @@ -587,7 +609,9 @@ async def test_rgb_color_temp_light( async def test_light_rgb( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic RGB light entity.""" mock_client.api_version = APIVersion(1, 7) @@ -704,7 +728,9 @@ async def test_light_rgb( async def test_light_rgbw( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic RGBW light entity.""" mock_client.api_version = APIVersion(1, 7) @@ -867,7 +893,9 @@ async def test_light_rgbw( async def test_light_rgbww_with_cold_warm_white_support( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic RGBWW light entity with cold warm white support.""" mock_client.api_version = APIVersion(1, 7) @@ -1107,7 +1135,9 @@ async def test_light_rgbww_with_cold_warm_white_support( async def test_light_rgbww_without_cold_warm_white_support( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic RGBWW light entity without cold warm white support.""" mock_client.api_version = APIVersion(1, 7) @@ -1337,7 +1367,9 @@ async def test_light_rgbww_without_cold_warm_white_support( async def test_light_color_temp( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity that does supports color temp.""" mock_client.api_version = APIVersion(1, 7) @@ -1409,7 +1441,9 @@ async def test_light_color_temp( async def test_light_color_temp_no_mireds_set( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic color temp with no mireds set uses the defaults.""" mock_client.api_version = APIVersion(1, 7) @@ -1501,7 +1535,9 @@ async def test_light_color_temp_no_mireds_set( async def test_light_color_temp_legacy( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a legacy light entity that does supports color temp.""" mock_client.api_version = APIVersion(1, 7) @@ -1583,7 +1619,9 @@ async def test_light_color_temp_legacy( async def test_light_rgb_legacy( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a legacy light entity that supports rgb.""" mock_client.api_version = APIVersion(1, 5) @@ -1679,7 +1717,9 @@ async def test_light_rgb_legacy( async def test_light_effects( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity that supports on and on and brightness.""" mock_client.api_version = APIVersion(1, 7) @@ -1731,7 +1771,9 @@ async def test_light_effects( async def test_only_cold_warm_white_support( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity with only cold warm white support.""" mock_client.api_version = APIVersion(1, 7) @@ -1827,7 +1869,9 @@ async def test_only_cold_warm_white_support( async def test_light_no_color_modes( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity with no color modes.""" mock_client.api_version = APIVersion(1, 7) diff --git a/tests/components/esphome/test_lock.py b/tests/components/esphome/test_lock.py index ae54b16d6e2..96c91b1d79f 100644 --- a/tests/components/esphome/test_lock.py +++ b/tests/components/esphome/test_lock.py @@ -20,9 +20,13 @@ from homeassistant.components.lock import ( from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + async def test_lock_entity_no_open( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic lock entity that does not support open.""" entity_info = [ @@ -58,7 +62,9 @@ async def test_lock_entity_no_open( async def test_lock_entity_start_locked( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic lock entity that does not support open.""" entity_info = [ @@ -83,7 +89,9 @@ async def test_lock_entity_start_locked( async def test_lock_entity_supports_open( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic lock entity that supports open.""" entity_info = [ diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 905a3f6bdc7..ac7c7ce1d47 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -1,7 +1,6 @@ """Test ESPHome manager.""" import asyncio -from collections.abc import Awaitable, Callable import logging from unittest.mock import AsyncMock, Mock, call @@ -9,8 +8,7 @@ from aioesphomeapi import ( APIClient, APIConnectionError, DeviceInfo, - EntityInfo, - EntityState, + EncryptionPlaintextAPIError, HomeassistantServiceCall, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, @@ -32,6 +30,8 @@ from homeassistant.components.esphome.const import ( STABLE_BLE_URL_VERSION, STABLE_BLE_VERSION_STR, ) +from homeassistant.components.esphome.manager import DEVICE_CONFLICT_ISSUE_FORMAT +from homeassistant.components.tag import DOMAIN as TAG_DOMAIN from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -40,22 +40,29 @@ from homeassistant.const import ( ) 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.exceptions import HomeAssistantError +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component -from .conftest import MockESPHomeDevice +from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType -from tests.common import MockConfigEntry, async_capture_events, async_mock_service +from tests.common import ( + MockConfigEntry, + async_call_logger_set_level, + async_capture_events, + async_mock_service, +) async def test_esphome_device_subscribe_logs( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, caplog: pytest.LogCaptureFixture, ) -> None: """Test configuring a device to subscribe to logs.""" @@ -70,7 +77,7 @@ async def test_esphome_device_subscribe_logs( options={CONF_SUBSCRIBE_LOGS: True}, ) entry.add_to_hass(hass) - device: MockESPHomeDevice = await mock_esphome_device( + device = await mock_esphome_device( mock_client=mock_client, entry=entry, entity_info=[], @@ -80,71 +87,56 @@ async def test_esphome_device_subscribe_logs( ) await hass.async_block_till_done() - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "DEBUG"}, - blocking=True, - ) - assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE + async with async_call_logger_set_level( + "homeassistant.components.esphome", "DEBUG", hass=hass, caplog=caplog + ): + assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE - caplog.set_level(logging.DEBUG) - device.mock_on_log_message( - Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message") - ) - await hass.async_block_till_done() - assert "test_log_message" in caplog.text + caplog.set_level(logging.DEBUG) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message") + ) + await hass.async_block_till_done() + assert "test_log_message" in caplog.text - device.mock_on_log_message( - Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message") - ) - await hass.async_block_till_done() - assert "test_error_log_message" in caplog.text + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message") + ) + await hass.async_block_till_done() + assert "test_error_log_message" in caplog.text - caplog.set_level(logging.ERROR) - device.mock_on_log_message( - Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") - ) - await hass.async_block_till_done() - assert "test_debug_log_message" not in caplog.text + caplog.set_level(logging.ERROR) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") + ) + await hass.async_block_till_done() + assert "test_debug_log_message" not in caplog.text - caplog.set_level(logging.DEBUG) - device.mock_on_log_message( - Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") - ) - await hass.async_block_till_done() - assert "test_debug_log_message" in caplog.text + caplog.set_level(logging.DEBUG) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") + ) + await hass.async_block_till_done() + assert "test_debug_log_message" in caplog.text - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "WARNING"}, - blocking=True, - ) - assert device.current_log_level == LogLevel.LOG_LEVEL_WARN - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "ERROR"}, - blocking=True, - ) - assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "INFO"}, - blocking=True, - ) - assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG + async with async_call_logger_set_level( + "homeassistant.components.esphome", "WARNING", hass=hass, caplog=caplog + ): + assert device.current_log_level == LogLevel.LOG_LEVEL_WARN + async with async_call_logger_set_level( + "homeassistant.components.esphome", "ERROR", hass=hass, caplog=caplog + ): + assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR + async with async_call_logger_set_level( + "homeassistant.components.esphome", "INFO", hass=hass, caplog=caplog + ): + assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG async def test_esphome_device_service_calls_not_allowed( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, caplog: pytest.LogCaptureFixture, issue_registry: ir.IssueRegistry, ) -> None: @@ -152,7 +144,7 @@ async def test_esphome_device_service_calls_not_allowed( entity_info = [] states = [] user_service = [] - device: MockESPHomeDevice = await mock_esphome_device( + device = await mock_esphome_device( mock_client=mock_client, entity_info=entity_info, user_service=user_service, @@ -184,22 +176,19 @@ async def test_esphome_device_service_calls_allowed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, caplog: pytest.LogCaptureFixture, issue_registry: ir.IssueRegistry, ) -> None: """Test a device with service calls are allowed.""" - await async_setup_component(hass, "tag", {}) + await async_setup_component(hass, TAG_DOMAIN, {}) entity_info = [] states = [] user_service = [] hass.config_entries.async_update_entry( mock_config_entry, options={CONF_ALLOW_SERVICE_CALLS: True} ) - device: MockESPHomeDevice = await mock_esphome_device( + device = await mock_esphome_device( mock_client=mock_client, entity_info=entity_info, user_service=user_service, @@ -344,10 +333,7 @@ async def test_esphome_device_service_calls_allowed( async def test_esphome_device_with_old_bluetooth( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, issue_registry: ir.IssueRegistry, ) -> None: """Test a device with old bluetooth creates an issue.""" @@ -374,10 +360,7 @@ async def test_esphome_device_with_old_bluetooth( async def test_esphome_device_with_password( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, issue_registry: ir.IssueRegistry, ) -> None: """Test a device with legacy password creates an issue.""" @@ -417,10 +400,7 @@ async def test_esphome_device_with_password( async def test_esphome_device_with_current_bluetooth( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, issue_registry: ir.IssueRegistry, ) -> None: """Test a device with recent bluetooth does not create an issue.""" @@ -449,7 +429,9 @@ async def test_esphome_device_with_current_bluetooth( @pytest.mark.usefixtures("mock_zeroconf") -async def test_unique_id_updated_to_mac(hass: HomeAssistant, mock_client) -> None: +async def test_unique_id_updated_to_mac( + hass: HomeAssistant, mock_client: APIClient +) -> None: """Test we update config entry unique ID to MAC address.""" entry = MockConfigEntry( domain=DOMAIN, @@ -687,6 +669,7 @@ async def test_connection_aborted_wrong_device( hass: HomeAssistant, mock_client: APIClient, caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, ) -> None: """Test we abort the connection if the unique id is a mac and neither name or mac match.""" entry = MockConfigEntry( @@ -720,6 +703,13 @@ async def test_connection_aborted_wrong_device( "with mac address `11:22:33:44:55:aa`, found `different` " "with mac address `11:22:33:44:55:ab`" in caplog.text ) + # If its a different name, it means their DHCP + # reservations are missing and the device is not + # actually the same device, and there is nothing + # we can do to fix it so we only log a warning + assert not issue_registry.async_get_issue( + domain=DOMAIN, issue_id=DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id) + ) assert "Error getting setting up connection for" not in caplog.text mock_client.disconnect = AsyncMock() @@ -739,10 +729,89 @@ async def test_connection_aborted_wrong_device( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "test", + "mac": "11:22:33:44:55:aa", + } assert entry.data[CONF_HOST] == "192.168.43.184" await hass.async_block_till_done() - assert len(new_info.mock_calls) == 1 + assert len(new_info.mock_calls) == 2 + assert "Unexpected device found at" not in caplog.text + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_connection_aborted_wrong_device_same_name( + hass: HomeAssistant, + mock_client: APIClient, + caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, +) -> None: + """Test we abort the connection if the unique id is a mac and the name matches.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.43.183", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + disconnect_done = hass.loop.create_future() + + async def async_disconnect(*args, **kwargs) -> None: + disconnect_done.set_result(None) + + mock_client.disconnect = async_disconnect + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(mac_address="1122334455ab", name="test") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + async with asyncio.timeout(1): + await disconnect_done + + assert ( + "Unexpected device found at 192.168.43.183; expected `test` " + "with mac address `11:22:33:44:55:aa`, found `test` " + "with mac address `11:22:33:44:55:ab`" in caplog.text + ) + # We should start a repair flow to help them fix the issue + assert issue_registry.async_get_issue( + domain=DOMAIN, issue_id=DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id) + ) + + assert "Error getting setting up connection for" not in caplog.text + mock_client.disconnect = AsyncMock() + caplog.clear() + # Make sure discovery triggers a reconnect + service_info = DhcpServiceInfo( + ip="192.168.43.184", + hostname="test", + macaddress="1122334455aa", + ) + new_info = AsyncMock( + return_value=DeviceInfo(mac_address="1122334455aa", name="test") + ) + mock_client.device_info = new_info + result = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "test", + "mac": "11:22:33:44:55:aa", + } + assert entry.data[CONF_HOST] == "192.168.43.184" + await hass.async_block_till_done() + assert len(new_info.mock_calls) == 2 assert "Unexpected device found at" not in caplog.text @@ -783,13 +852,10 @@ async def test_failure_during_connect( async def test_state_subscription( mock_client: APIClient, hass: HomeAssistant, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test ESPHome subscribes to state changes.""" - device: MockESPHomeDevice = await mock_esphome_device( + device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -846,13 +912,10 @@ async def test_state_subscription( async def test_state_request( mock_client: APIClient, hass: HomeAssistant, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test ESPHome requests state change.""" - device: MockESPHomeDevice = await mock_esphome_device( + device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -874,10 +937,8 @@ async def test_state_request( async def test_debug_logging( mock_client: APIClient, hass: HomeAssistant, - mock_generic_device_entry: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockConfigEntry], - ], + mock_generic_device_entry: MockGenericDeviceEntryType, + caplog: pytest.LogCaptureFixture, ) -> None: """Test enabling and disabling debug logging.""" assert await async_setup_component(hass, "logger", {"logger": {}}) @@ -887,33 +948,22 @@ async def test_debug_logging( user_service=[], states=[], ) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "DEBUG"}, - blocking=True, - ) - await hass.async_block_till_done() - mock_client.set_debug.assert_has_calls([call(True)]) + async with async_call_logger_set_level( + "homeassistant.components.esphome", "DEBUG", hass=hass, caplog=caplog + ): + mock_client.set_debug.assert_has_calls([call(True)]) + mock_client.reset_mock() - mock_client.reset_mock() - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "WARNING"}, - blocking=True, - ) - await hass.async_block_till_done() - mock_client.set_debug.assert_has_calls([call(False)]) + async with async_call_logger_set_level( + "homeassistant.components.esphome", "WARNING", hass=hass, caplog=caplog + ): + mock_client.set_debug.assert_has_calls([call(False)]) async def test_esphome_device_with_dash_in_name_user_services( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with user services and a dash in the name.""" entity_info = [] @@ -982,10 +1032,7 @@ async def test_esphome_device_with_dash_in_name_user_services( async def test_esphome_user_services_ignores_invalid_arg_types( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with user services and a dash in the name.""" entity_info = [] @@ -1044,13 +1091,66 @@ async def test_esphome_user_services_ignores_invalid_arg_types( assert not hass.services.has_service(DOMAIN, "with_dash_bad_service") +async def test_esphome_user_service_fails( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test executing a user service fails due to disconnect.""" + entity_info = [] + states = [] + service1 = UserService( + name="simple_service", + key=2, + args=[ + UserServiceArg(name="arg1", type=UserServiceArgType.BOOL), + ], + ) + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=[service1], + device_info={"name": "with-dash"}, + states=states, + ) + await hass.async_block_till_done() + assert hass.services.has_service(DOMAIN, "with_dash_simple_service") + + mock_client.execute_service = Mock(side_effect=APIConnectionError("fail")) + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call( + DOMAIN, "with_dash_simple_service", {"arg1": True}, blocking=True + ) + assert exc.value.translation_domain == DOMAIN + assert exc.value.translation_key == "action_call_failed" + assert exc.value.translation_placeholders == { + "call_name": "simple_service", + "device_name": "with-dash", + "error": "fail", + } + assert ( + str(exc.value) + == "Failed to execute the action call simple_service on with-dash: fail" + ) + + mock_client.execute_service.assert_has_calls( + [ + call( + UserService( + name="simple_service", + key=2, + args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], + ), + {"arg1": True}, + ) + ] + ) + + async def test_esphome_user_services_changes( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with user services that change arguments.""" entity_info = [] @@ -1129,10 +1229,7 @@ async def test_esphome_device_with_suggested_area( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with suggested area.""" device = await mock_esphome_device( @@ -1154,10 +1251,7 @@ async def test_esphome_device_with_project( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with a project.""" device = await mock_esphome_device( @@ -1181,10 +1275,7 @@ async def test_esphome_device_with_manufacturer( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with a manufacturer.""" device = await mock_esphome_device( @@ -1206,10 +1297,7 @@ async def test_esphome_device_with_web_server( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with a web server.""" device = await mock_esphome_device( @@ -1231,10 +1319,7 @@ async def test_esphome_device_with_ipv6_web_server( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with a web server.""" entry = MockConfigEntry( @@ -1267,10 +1352,7 @@ async def test_esphome_device_with_compilation_time( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with a compilation_time.""" device = await mock_esphome_device( @@ -1291,10 +1373,7 @@ async def test_esphome_device_with_compilation_time( async def test_disconnects_at_close_event( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test the device is disconnected at the close event.""" await mock_esphome_device( @@ -1316,6 +1395,7 @@ async def test_disconnects_at_close_event( @pytest.mark.parametrize( "error", [ + EncryptionPlaintextAPIError, RequiresEncryptionAPIError, InvalidEncryptionKeyAPIError, InvalidAuthAPIError, @@ -1324,10 +1404,7 @@ async def test_disconnects_at_close_event( async def test_start_reauth( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, error: Exception, ) -> None: """Test exceptions on connect error trigger reauth.""" @@ -1349,13 +1426,43 @@ async def test_start_reauth( assert flow["context"]["source"] == "reauth" +async def test_no_reauth_wrong_mac( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test exceptions on connect error trigger reauth.""" + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + device_info={"compilation_time": "comp_time"}, + states=[], + ) + await hass.async_block_till_done() + + await device.mock_connect_error( + InvalidEncryptionKeyAPIError( + "fail", received_mac="aabbccddeeff", received_name="test" + ) + ) + await hass.async_block_till_done() + + # Reauth should not be triggered + flows = hass.config_entries.flow.async_progress(DOMAIN) + assert len(flows) == 0 + assert ( + "Unexpected device found at test.local; expected `test` " + "with mac address `11:22:33:44:55:aa`, found `test` " + "with mac address `aa:bb:cc:dd:ee:ff`" in caplog.text + ) + + async def test_entry_missing_unique_id( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test the unique id is added from storage if available.""" entry = MockConfigEntry( @@ -1377,10 +1484,7 @@ async def test_entry_missing_unique_id( async def test_entry_missing_bluetooth_mac_address( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test the bluetooth_mac_address is added if available.""" entry = MockConfigEntry( @@ -1401,3 +1505,98 @@ async def test_entry_missing_bluetooth_mac_address( ) await hass.async_block_till_done() assert entry.data[CONF_BLUETOOTH_MAC_ADDRESS] == "AA:BB:CC:DD:EE:FC" + + +async def test_device_adds_friendly_name( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a device with user services that change arguments.""" + entity_info = [] + states = [] + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=[], + device_info={"name": "nofriendlyname", "friendly_name": ""}, + states=states, + ) + await hass.async_block_till_done() + dev_reg = dr.async_get(hass) + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.entry.unique_id)} + ) + assert dev.name == "Nofriendlyname" + assert ( + "No `friendly_name` set in the `esphome:` section of " + "the YAML config for device 'nofriendlyname'" + ) in caplog.text + caplog.clear() + + await device.mock_disconnect(True) + await hass.async_block_till_done() + device.device_info = DeviceInfo( + **{**device.device_info.to_dict(), "friendly_name": "I have a friendly name"} + ) + mock_client.device_info = AsyncMock(return_value=device.device_info) + await device.mock_connect() + await hass.async_block_till_done() + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.entry.unique_id)} + ) + assert dev.name == "I have a friendly name" + assert ( + "No `friendly_name` set in the `esphome:` section of the YAML config for device" + ) not in caplog.text + + +async def test_assist_in_progress_issue_deleted( + hass: HomeAssistant, + mock_client: APIClient, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test assist in progress entity and issue is deleted. + + Remove this cleanup after 2026.4 + """ + entry = entity_registry.async_get_or_create( + domain=DOMAIN, + platform="binary_sensor", + unique_id="11:22:33:44:55:AA-assist_in_progress", + ) + ir.async_create_issue( + hass, + DOMAIN, + f"assist_in_progress_deprecated_{entry.id}", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="assist_in_progress_deprecated", + translation_placeholders={ + "integration_name": "ESPHome", + }, + ) + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + device_info={}, + states=[], + mock_storage=True, + ) + assert ( + entity_registry.async_get_entity_id( + DOMAIN, "binary_sensor", "11:22:33:44:55:AA-assist_in_progress" + ) + is None + ) + assert ( + issue_registry.async_get_issue( + DOMAIN, f"assist_in_progress_deprecated_{entry.id}" + ) + is None + ) diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index a425b730771..18a997dc09a 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -1,12 +1,9 @@ """Test ESPHome media_players.""" -from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, Mock, call, patch from aioesphomeapi import ( APIClient, - EntityInfo, - EntityState, MediaPlayerCommand, MediaPlayerEntityState, MediaPlayerFormatPurpose, @@ -41,14 +38,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from .conftest import MockESPHomeDevice +from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType from tests.common import mock_platform from tests.typing import WebSocketGenerator async def test_media_player_entity( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic media_player entity.""" entity_info = [ @@ -160,7 +159,7 @@ async def test_media_player_entity_with_source( hass: HomeAssistant, mock_client: APIClient, hass_ws_client: WebSocketGenerator, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic media_player entity media source.""" await async_setup_component(hass, "media_source", {"media_source": {}}) @@ -293,13 +292,10 @@ async def test_media_player_proxy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a media_player entity with a proxy URL.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[ MediaPlayerInfo( diff --git a/tests/components/esphome/test_number.py b/tests/components/esphome/test_number.py index 557425052f3..9a711f2766e 100644 --- a/tests/components/esphome/test_number.py +++ b/tests/components/esphome/test_number.py @@ -21,11 +21,13 @@ from homeassistant.const import ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, STATE_ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from .conftest import MockGenericDeviceEntryType + async def test_generic_number_entity( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic number entity.""" entity_info = [ @@ -65,7 +67,7 @@ async def test_generic_number_entity( async def test_generic_number_nan( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic number entity with nan state.""" entity_info = [ @@ -97,7 +99,7 @@ async def test_generic_number_nan( async def test_generic_number_with_unit_of_measurement_as_empty_string( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic number entity with nan state.""" entity_info = [ @@ -130,7 +132,7 @@ async def test_generic_number_with_unit_of_measurement_as_empty_string( async def test_generic_number_entity_set_when_disconnected( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic number entity.""" entity_info = [ diff --git a/tests/components/esphome/test_repairs.py b/tests/components/esphome/test_repairs.py index c365e65cbe1..268b30f8b52 100644 --- a/tests/components/esphome/test_repairs.py +++ b/tests/components/esphome/test_repairs.py @@ -1,13 +1,246 @@ """Test ESPHome repairs.""" +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock + +from aioesphomeapi import APIClient, BinarySensorInfo, BinarySensorState, DeviceInfo import pytest from homeassistant.components.esphome import repairs +from homeassistant.components.esphome.const import DOMAIN +from homeassistant.components.esphome.manager import DEVICE_CONFLICT_ISSUE_FORMAT +from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) + +from .conftest import MockESPHomeDeviceType + +from tests.common import MockConfigEntry +from tests.components.repairs import ( + async_process_repairs_platforms, + get_repairs, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator, WebSocketGenerator async def test_create_fix_flow_raises_on_unknown_issue_id(hass: HomeAssistant) -> None: - """Test reate_fix_flow raises on unknown issue_id.""" + """Test create_fix_flow raises on unknown issue_id.""" with pytest.raises(ValueError): await repairs.async_create_fix_flow(hass, "no_such_issue", None) + + +async def test_device_conflict_manual( + hass: HomeAssistant, + mock_client: APIClient, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test guided manual conflict resolution.""" + disconnect_done = hass.loop.create_future() + + async def async_disconnect(*args, **kwargs) -> None: + disconnect_done.set_result(None) + + mock_client.disconnect = async_disconnect + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + mac_address="1122334455ab", name="test", model="esp32-iso-poe" + ) + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + async with asyncio.timeout(1): + await disconnect_done + + assert "Unexpected device found" in caplog.text + issue_id = DEVICE_CONFLICT_ISSUE_FORMAT.format(mock_config_entry.entry_id) + + issues = await get_repairs(hass, hass_ws_client) + assert issues + assert len(issues) == 1 + assert any(True for issue in issues if issue["issue_id"] == issue_id) + + await async_process_repairs_platforms(hass) + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "ip": "192.168.1.2", + "mac": "11:22:33:44:55:ab", + "model": "esp32-iso-poe", + "name": "test", + "stored_mac": "11:22:33:44:55:aa", + } + assert data["type"] == FlowResultType.MENU + assert data["step_id"] == "init" + + data = await process_repair_fix_flow( + client, flow_id, json={"next_step_id": "manual"} + ) + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "ip": "192.168.1.2", + "mac": "11:22:33:44:55:ab", + "model": "esp32-iso-poe", + "name": "test", + "stored_mac": "11:22:33:44:55:aa", + } + assert data["type"] == FlowResultType.FORM + assert data["step_id"] == "manual" + + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + mac_address="11:22:33:44:55:aa", name="test", model="esp32-iso-poe" + ) + ) + caplog.clear() + data = await process_repair_fix_flow(client, flow_id) + + assert data["type"] == FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + assert "Unexpected device found" not in caplog.text + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None + + +async def test_device_conflict_migration( + hass: HomeAssistant, + mock_client: APIClient, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test migrating existing configuration to new hardware.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + is_status_binary_sensor=True, + ) + ] + states = [BinarySensorState(key=1, state=None)] + user_service = [] + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_ON + mock_config_entry = device.entry + + ent_reg_entry = entity_registry.async_get("binary_sensor.test_mybinary_sensor") + assert ent_reg_entry + assert ent_reg_entry.unique_id == "11:22:33:44:55:AA-binary_sensor-mybinary_sensor" + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert entries is not None + for entry in entries: + assert entry.unique_id.startswith("11:22:33:44:55:AA-") + disconnect_done = hass.loop.create_future() + + async def async_disconnect(*args, **kwargs) -> None: + if not disconnect_done.done(): + disconnect_done.set_result(None) + + mock_client.disconnect = async_disconnect + new_device_info = DeviceInfo( + mac_address="11:22:33:44:55:AB", name="test", model="esp32-iso-poe" + ) + mock_client.device_info = AsyncMock(return_value=new_device_info) + device.device_info = new_device_info + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + async with asyncio.timeout(1): + await disconnect_done + + assert "Unexpected device found" in caplog.text + issue_id = DEVICE_CONFLICT_ISSUE_FORMAT.format(mock_config_entry.entry_id) + + issues = await get_repairs(hass, hass_ws_client) + assert issues + assert len(issues) == 1 + assert any(True for issue in issues if issue["issue_id"] == issue_id) + + await async_process_repairs_platforms(hass) + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "ip": "test.local", + "mac": "11:22:33:44:55:ab", + "model": "esp32-iso-poe", + "name": "test", + "stored_mac": "11:22:33:44:55:aa", + } + assert data["type"] == FlowResultType.MENU + assert data["step_id"] == "init" + + data = await process_repair_fix_flow( + client, flow_id, json={"next_step_id": "migrate"} + ) + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "ip": "test.local", + "mac": "11:22:33:44:55:ab", + "model": "esp32-iso-poe", + "name": "test", + "stored_mac": "11:22:33:44:55:aa", + } + assert data["type"] == FlowResultType.FORM + assert data["step_id"] == "migrate" + + caplog.clear() + data = await process_repair_fix_flow(client, flow_id) + + assert data["type"] == FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + assert "Unexpected device found" not in caplog.text + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None + + assert mock_config_entry.unique_id == "11:22:33:44:55:ab" + ent_reg_entry = entity_registry.async_get("binary_sensor.test_mybinary_sensor") + assert ent_reg_entry + assert ent_reg_entry.unique_id == "11:22:33:44:55:AB-binary_sensor-mybinary_sensor" + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert entries is not None + for entry in entries: + assert entry.unique_id.startswith("11:22:33:44:55:AB-") + + dev_entry = device_registry.async_get_device( + identifiers={}, connections={(dr.CONNECTION_NETWORK_MAC, "11:22:33:44:55:ab")} + ) + assert dev_entry is not None + + old_dev_entry = device_registry.async_get_device( + identifiers={}, connections={(dr.CONNECTION_NETWORK_MAC, "11:22:33:44:55:aa")} + ) + assert old_dev_entry is None diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index 6ae1260a89d..09a8f739e71 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -2,8 +2,13 @@ from unittest.mock import call -from aioesphomeapi import APIClient, SelectInfo, SelectState +from aioesphomeapi import APIClient, SelectInfo, SelectState, VoiceAssistantFeature +import pytest +from homeassistant.components.assist_satellite import ( + AssistSatelliteConfiguration, + AssistSatelliteWakeWord, +) from homeassistant.components.select import ( ATTR_OPTION, DOMAIN as SELECT_DOMAIN, @@ -12,10 +17,13 @@ from homeassistant.components.select import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from .common import get_satellite_entity +from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType + +@pytest.mark.usefixtures("mock_voice_assistant_v1_entry") async def test_pipeline_selector( hass: HomeAssistant, - mock_voice_assistant_v1_entry, ) -> None: """Test assist pipeline selector.""" @@ -24,9 +32,9 @@ async def test_pipeline_selector( assert state.state == "preferred" +@pytest.mark.usefixtures("mock_voice_assistant_v1_entry") async def test_vad_sensitivity_select( hass: HomeAssistant, - mock_voice_assistant_v1_entry, ) -> None: """Test VAD sensitivity select. @@ -38,9 +46,9 @@ async def test_vad_sensitivity_select( assert state.state == "default" +@pytest.mark.usefixtures("mock_voice_assistant_v1_entry") async def test_wake_word_select( hass: HomeAssistant, - mock_voice_assistant_v1_entry, ) -> None: """Test that wake word select is unavailable initially.""" state = hass.states.get("select.test_wake_word") @@ -49,7 +57,9 @@ async def test_wake_word_select( async def test_select_generic_entity( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic select entity.""" entity_info = [ @@ -80,3 +90,113 @@ async def test_select_generic_entity( blocking=True, ) mock_client.select_command.assert_has_calls([call(1, "b")]) + + +async def test_wake_word_select_no_wake_words( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test wake word select is unavailable when there are no available wake word.""" + device_config = AssistSatelliteConfiguration( + available_wake_words=[], + active_wake_words=[], + max_active_wake_words=1, + ) + mock_client.get_voice_assistant_configuration.return_value = device_config + + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + assert not satellite.async_get_configuration().available_wake_words + + # Select should be unavailable + state = hass.states.get("select.test_wake_word") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_wake_word_select_zero_max_wake_words( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test wake word select is unavailable max wake words is zero.""" + device_config = AssistSatelliteConfiguration( + available_wake_words=[ + AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), + ], + active_wake_words=[], + max_active_wake_words=0, + ) + mock_client.get_voice_assistant_configuration.return_value = device_config + + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + assert satellite.async_get_configuration().max_active_wake_words == 0 + + # Select should be unavailable + state = hass.states.get("select.test_wake_word") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_wake_word_select_no_active_wake_words( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test wake word select uses first available wake word if none are active.""" + device_config = AssistSatelliteConfiguration( + available_wake_words=[ + AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), + AssistSatelliteWakeWord("hey_jarvis", "Hey Jarvis", ["en"]), + ], + active_wake_words=[], + max_active_wake_words=1, + ) + mock_client.get_voice_assistant_configuration.return_value = device_config + + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + assert not satellite.async_get_configuration().active_wake_words + + # First available wake word should be selected + state = hass.states.get("select.test_wake_word") + assert state is not None + assert state.state == "Okay Nabu" diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index 76f71b53167..0c443dc5941 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -1,21 +1,17 @@ """Test ESPHome sensors.""" -from collections.abc import Awaitable, Callable import logging import math from aioesphomeapi import ( APIClient, EntityCategory as ESPHomeEntityCategory, - EntityInfo, - EntityState, LastResetType, SensorInfo, SensorState, SensorStateClass as ESPHomeSensorStateClass, TextSensorInfo, TextSensorState, - UserService, ) from homeassistant.components.sensor import ( @@ -33,16 +29,13 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockESPHomeDevice +from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType async def test_generic_numeric_sensor( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a generic sensor entity.""" logging.getLogger("homeassistant.components.esphome").setLevel(logging.DEBUG) @@ -99,7 +92,7 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic sensor entity.""" entity_info = [ @@ -136,7 +129,7 @@ async def test_generic_numeric_sensor_state_class_measurement( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic sensor entity.""" entity_info = [ @@ -173,7 +166,7 @@ async def test_generic_numeric_sensor_state_class_measurement( async def test_generic_numeric_sensor_device_class_timestamp( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a sensor entity that uses timestamp (epoch).""" entity_info = [ @@ -201,7 +194,7 @@ async def test_generic_numeric_sensor_device_class_timestamp( async def test_generic_numeric_sensor_legacy_last_reset_convert( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a state class of measurement with last reset type of auto is converted to total increasing.""" entity_info = [ @@ -229,7 +222,9 @@ async def test_generic_numeric_sensor_legacy_last_reset_convert( async def test_generic_numeric_sensor_no_state( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic numeric sensor that has no state.""" entity_info = [ @@ -254,7 +249,9 @@ async def test_generic_numeric_sensor_no_state( async def test_generic_numeric_sensor_nan_state( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic numeric sensor that has nan state.""" entity_info = [ @@ -279,7 +276,9 @@ async def test_generic_numeric_sensor_nan_state( async def test_generic_numeric_sensor_missing_state( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic numeric sensor that is missing state.""" entity_info = [ @@ -306,7 +305,7 @@ async def test_generic_numeric_sensor_missing_state( async def test_generic_text_sensor( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic text sensor entity.""" entity_info = [ @@ -331,7 +330,9 @@ async def test_generic_text_sensor( async def test_generic_text_sensor_missing_state( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic text sensor that is missing state.""" entity_info = [ @@ -358,7 +359,7 @@ async def test_generic_text_sensor_missing_state( async def test_generic_text_sensor_device_class_timestamp( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a sensor entity that uses timestamp (datetime).""" entity_info = [ @@ -387,7 +388,7 @@ async def test_generic_text_sensor_device_class_timestamp( async def test_generic_text_sensor_device_class_date( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a sensor entity that uses date (datetime).""" entity_info = [ @@ -414,7 +415,9 @@ async def test_generic_text_sensor_device_class_date( async def test_generic_numeric_sensor_empty_string_uom( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic numeric sensor that has an empty string as the uom.""" entity_info = [ diff --git a/tests/components/esphome/test_switch.py b/tests/components/esphome/test_switch.py index 561ac0b369f..b3c13ee2fe5 100644 --- a/tests/components/esphome/test_switch.py +++ b/tests/components/esphome/test_switch.py @@ -12,9 +12,13 @@ from homeassistant.components.switch import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_ON from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + async def test_switch_generic_entity( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic switch entity.""" entity_info = [ diff --git a/tests/components/esphome/test_text.py b/tests/components/esphome/test_text.py index 07157d98ac6..899b4a732ca 100644 --- a/tests/components/esphome/test_text.py +++ b/tests/components/esphome/test_text.py @@ -12,11 +12,13 @@ from homeassistant.components.text import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + async def test_generic_text_entity( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic text entity.""" entity_info = [ @@ -56,7 +58,7 @@ async def test_generic_text_entity( async def test_generic_text_entity_no_state( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic text entity that has no state.""" entity_info = [ @@ -87,7 +89,7 @@ async def test_generic_text_entity_no_state( async def test_generic_text_entity_missing_state( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic text entity that has no state.""" entity_info = [ diff --git a/tests/components/esphome/test_time.py b/tests/components/esphome/test_time.py index aaa18c77a47..543a903f0a9 100644 --- a/tests/components/esphome/test_time.py +++ b/tests/components/esphome/test_time.py @@ -12,11 +12,13 @@ from homeassistant.components.time import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + async def test_generic_time_entity( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic time entity.""" entity_info = [ @@ -52,7 +54,7 @@ async def test_generic_time_entity( async def test_generic_time_missing_state( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic time entity with missing state.""" entity_info = [ diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 76c0a9b1a70..c9b88d9fb57 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -1,18 +1,9 @@ """Test ESPHome update entities.""" -from collections.abc import Awaitable, Callable from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import patch -from aioesphomeapi import ( - APIClient, - EntityInfo, - EntityState, - UpdateCommand, - UpdateInfo, - UpdateState, - UserService, -) +from aioesphomeapi import APIClient, UpdateCommand, UpdateInfo, UpdateState import pytest from homeassistant.components.esphome.dashboard import async_get_dashboard @@ -35,7 +26,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import MockESPHomeDevice +from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType @pytest.fixture(autouse=True) @@ -91,10 +82,7 @@ async def test_update_entity( expected_state: str, expected_attributes: dict[str, Any], mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test ESPHome update entity.""" mock_dashboard["configured"] = devices_payload @@ -119,10 +107,12 @@ async def test_update_entity( # Compile failed, don't try to upload with ( patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=False + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=False, ) as mock_compile, patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=True + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, ) as mock_upload, pytest.raises( HomeAssistantError, @@ -130,9 +120,9 @@ async def test_update_entity( ), ): await hass.services.async_call( - "update", - "install", - {"entity_id": "update.test_firmware"}, + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, blocking=True, ) @@ -144,10 +134,12 @@ async def test_update_entity( # Compile success, upload fails with ( patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=True + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, ) as mock_compile, patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=False + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=False, ) as mock_upload, pytest.raises( HomeAssistantError, @@ -155,9 +147,9 @@ async def test_update_entity( ), ): await hass.services.async_call( - "update", - "install", - {"entity_id": "update.test_firmware"}, + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, blocking=True, ) @@ -170,16 +162,18 @@ async def test_update_entity( # Everything works with ( patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=True + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, ) as mock_compile, patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=True + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, ) as mock_upload, ): await hass.services.async_call( - "update", - "install", - {"entity_id": "update.test_firmware"}, + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, blocking=True, ) @@ -193,10 +187,7 @@ async def test_update_entity( async def test_update_static_info( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, mock_dashboard: dict[str, Any], ) -> None: """Test ESPHome update entity.""" @@ -208,7 +199,7 @@ async def test_update_static_info( ] await async_get_dashboard(hass).async_refresh() - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -245,10 +236,7 @@ async def test_update_device_state_for_availability( has_deep_sleep: bool, mock_dashboard: dict[str, Any], mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test ESPHome update entity changes availability with the device.""" mock_dashboard["configured"] = [ @@ -277,16 +265,13 @@ async def test_update_device_state_for_availability( async def test_update_entity_dashboard_not_available_startup( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, mock_dashboard: dict[str, Any], ) -> None: """Test ESPHome update entity when dashboard is not available at startup.""" with ( patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_devices", side_effect=TimeoutError, ), ): @@ -326,15 +311,12 @@ async def test_update_entity_dashboard_not_available_startup( async def test_update_entity_dashboard_discovered_after_startup_but_update_failed( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, mock_dashboard: dict[str, Any], ) -> None: """Test ESPHome update entity when dashboard is discovered after startup and the first update fails.""" with patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_devices", side_effect=TimeoutError, ): await async_get_dashboard(hass).async_refresh() @@ -374,27 +356,26 @@ async def test_update_entity_dashboard_discovered_after_startup_but_update_faile async def test_update_entity_not_present_without_dashboard( - hass: HomeAssistant, stub_reconnect, mock_config_entry, mock_device_info + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test ESPHome update entity does not get created if there is no dashboard.""" - with patch( - "homeassistant.components.esphome.update.DomainData.get_entry_data", - return_value=Mock(available=True, device_info=mock_device_info, info={}), - ): - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) - state = hass.states.get("update.none_firmware") + state = hass.states.get("update.test_firmware") assert state is None async def test_update_becomes_available_at_runtime( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, mock_dashboard: dict[str, Any], ) -> None: """Test ESPHome update entity when the dashboard has no device at startup but gets them later.""" @@ -430,10 +411,7 @@ async def test_update_becomes_available_at_runtime( async def test_update_entity_not_present_with_dashboard_but_unknown_device( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, mock_dashboard: dict[str, Any], ) -> None: """Test ESPHome update entity does not get created if the device is unknown to the dashboard.""" @@ -465,7 +443,7 @@ async def test_update_entity_not_present_with_dashboard_but_unknown_device( async def test_generic_device_update_entity( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic device update entity.""" entity_info = [ @@ -501,10 +479,7 @@ async def test_generic_device_update_entity( async def test_generic_device_update_entity_has_update( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a generic device update entity with an update.""" entity_info = [ @@ -526,7 +501,7 @@ async def test_generic_device_update_entity_has_update( ) ] user_service = [] - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=entity_info, user_service=user_service, diff --git a/tests/components/esphome/test_valve.py b/tests/components/esphome/test_valve.py index 7a7e22b1713..bc5c77a62d6 100644 --- a/tests/components/esphome/test_valve.py +++ b/tests/components/esphome/test_valve.py @@ -1,13 +1,9 @@ """Test ESPHome valves.""" -from collections.abc import Awaitable, Callable from unittest.mock import call from aioesphomeapi import ( APIClient, - EntityInfo, - EntityState, - UserService, ValveInfo, ValveOperation, ValveState as ESPHomeValveState, @@ -26,16 +22,13 @@ from homeassistant.components.valve import ( from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from .conftest import MockESPHomeDevice +from .conftest import MockESPHomeDeviceType async def test_valve_entity( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a generic valve entity.""" entity_info = [ @@ -133,10 +126,7 @@ async def test_valve_entity( async def test_valve_entity_without_position( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a generic valve entity without position or stop.""" entity_info = [ diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 55b7e35132c..53cecd78bb6 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch +from pyfibaro.fibaro_device import SceneEvent import pytest from homeassistant.components.fibaro import CONF_IMPORT_PLUGINS, DOMAIN @@ -69,6 +70,11 @@ def mock_power_sensor() -> Mock: } sensor.actions = {} sensor.has_central_scene_event = False + sensor.raw_data = { + "fibaro_id": 1, + "name": "Test sensor", + "properties": {"power": 6.6, "password": "mysecret"}, + } value_mock = Mock() value_mock.has_value = False value_mock.is_bool_value = False @@ -122,6 +128,7 @@ def mock_light() -> Mock: light.properties = {"manufacturer": ""} light.actions = {"setValue": 1, "on": 0, "off": 0} light.supported_features = {} + light.raw_data = {"fibaro_id": 3, "name": "Test light", "properties": {"value": 20}} value_mock = Mock() value_mock.has_value = True value_mock.int_value.return_value = 20 @@ -231,6 +238,26 @@ def mock_fan_device() -> Mock: return climate +@pytest.fixture +def mock_button_device() -> Mock: + """Fixture for a button device.""" + climate = Mock() + climate.fibaro_id = 8 + climate.parent_fibaro_id = 0 + climate.name = "Test button" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.remoteController" + climate.base_type = "com.fibaro.actor" + climate.properties = {"manufacturer": ""} + climate.central_scene_event = [SceneEvent(1, "Pressed")] + climate.actions = {} + climate.interfaces = ["zwaveCentralScene"] + return climate + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/fibaro/snapshots/test_diagnostics.ambr b/tests/components/fibaro/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..e9d5e48e08c --- /dev/null +++ b/tests/components/fibaro/snapshots/test_diagnostics.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics + dict({ + 'config': dict({ + 'import_plugins': True, + }), + 'fibaro_devices': list([ + dict({ + 'fibaro_id': 3, + 'name': 'Test light', + 'properties': dict({ + 'value': 20, + }), + }), + ]), + }) +# --- +# name: test_device_diagnostics + dict({ + 'config': dict({ + 'import_plugins': True, + }), + 'fibaro_devices': list([ + dict({ + 'fibaro_id': 3, + 'name': 'Test light', + 'properties': dict({ + 'value': 20, + }), + }), + ]), + }) +# --- +# name: test_device_diagnostics_for_hub + dict({ + 'config': dict({ + 'import_plugins': True, + }), + 'fibaro_devices': list([ + dict({ + 'fibaro_id': 3, + 'name': 'Test light', + 'properties': dict({ + 'value': 20, + }), + }), + dict({ + 'fibaro_id': 1, + 'name': 'Test sensor', + 'properties': dict({ + 'password': '**REDACTED**', + 'power': 6.6, + }), + }), + ]), + }) +# --- diff --git a/tests/components/fibaro/test_diagnostics.py b/tests/components/fibaro/test_diagnostics.py new file mode 100644 index 00000000000..c6148e0cc33 --- /dev/null +++ b/tests/components/fibaro/test_diagnostics.py @@ -0,0 +1,96 @@ +"""Tests for the diagnostics data provided by the fibaro integration.""" + +from unittest.mock import Mock + +from syrupy import SnapshotAssertion + +from homeassistant.components.fibaro import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .conftest import TEST_SERIALNUMBER, init_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +async def test_config_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + # Act + await init_integration(hass, mock_config_entry) + # Assert + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) + + +async def test_device_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + # Act + await init_integration(hass, mock_config_entry) + entry = entity_registry.async_get("light.room_1_test_light_3") + device = device_registry.async_get(entry.device_id) + # Assert + assert device + assert ( + await get_diagnostics_for_device(hass, hass_client, mock_config_entry, device) + == snapshot + ) + + +async def test_device_diagnostics_for_hub( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_power_sensor: Mock, + mock_room: Mock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for the hub.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light, mock_power_sensor] + # Act + await init_integration(hass, mock_config_entry) + device = device_registry.async_get_device({(DOMAIN, TEST_SERIALNUMBER)}) + # Assert + assert device + assert ( + await get_diagnostics_for_device(hass, hass_client, mock_config_entry, device) + == snapshot + ) diff --git a/tests/components/fibaro/test_event.py b/tests/components/fibaro/test_event.py new file mode 100644 index 00000000000..ced39b71197 --- /dev/null +++ b/tests/components/fibaro/test_event.py @@ -0,0 +1,35 @@ +"""Test the Fibaro event platform.""" + +from unittest.mock import Mock, patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry + + +async def test_entity_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_button_device: Mock, + mock_room: Mock, +) -> None: + """Test that the button device creates an entity.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_button_device] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.EVENT]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + entry = entity_registry.async_get("event.room_1_test_button_8_button_1") + assert entry + assert entry.unique_id == "hc2_111111.8.1" + assert entry.original_name == "Room 1 Test button Button 1" diff --git a/tests/components/fibaro/test_init.py b/tests/components/fibaro/test_init.py new file mode 100644 index 00000000000..330de74d6af --- /dev/null +++ b/tests/components/fibaro/test_init.py @@ -0,0 +1,31 @@ +"""Test init methods.""" + +from unittest.mock import Mock, patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .conftest import init_integration + +from tests.common import MockConfigEntry + + +async def test_unload_integration( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, +) -> None: + """Test unload integration stops state listener.""" + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + # Act + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + # Assert + assert mock_fibaro_client.unregister_update_handler.call_count == 1 diff --git a/tests/components/flexit_bacnet/test_climate.py b/tests/components/flexit_bacnet/test_climate.py index be361541c39..e3c04a1a48f 100644 --- a/tests/components/flexit_bacnet/test_climate.py +++ b/tests/components/flexit_bacnet/test_climate.py @@ -27,7 +27,7 @@ from homeassistant.components.flexit_bacnet.const import PRESET_TO_VENTILATION_M from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_component, entity_registry as er from . import setup_with_selected_platforms @@ -156,14 +156,14 @@ async def test_hvac_action( # Simulate electric heater being ON mock_flexit_bacnet.electric_heater = True - await hass.helpers.entity_component.async_update_entity(ENTITY_ID) + await entity_component.async_update_entity(hass, ENTITY_ID) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING # Simulate electric heater being OFF mock_flexit_bacnet.electric_heater = False - await hass.helpers.entity_component.async_update_entity(ENTITY_ID) + await entity_component.async_update_entity(hass, ENTITY_ID) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.FAN diff --git a/tests/components/flipr/test_init.py b/tests/components/flipr/test_init.py index 6e9341b1e06..50a240958f8 100644 --- a/tests/components/flipr/test_init.py +++ b/tests/components/flipr/test_init.py @@ -2,9 +2,7 @@ from unittest.mock import AsyncMock -from homeassistant.components.flipr.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from . import setup_integration @@ -29,62 +27,3 @@ async def test_unload_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_duplicate_config_entries( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_flipr_client: AsyncMock, -) -> None: - """Test duplicate config entries.""" - - mock_config_entry_dup = MockConfigEntry( - version=2, - domain=DOMAIN, - unique_id="toto@toto.com", - data={ - CONF_EMAIL: "toto@toto.com", - CONF_PASSWORD: "myPassword", - "flipr_id": "myflipr_id_dup", - }, - ) - - mock_config_entry.add_to_hass(hass) - # Initialize the first entry with default mock - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Initialize the second entry with another flipr id - mock_config_entry_dup.add_to_hass(hass) - assert not await hass.config_entries.async_setup(mock_config_entry_dup.entry_id) - await hass.async_block_till_done() - assert mock_config_entry_dup.state is ConfigEntryState.SETUP_ERROR - - -async def test_migrate_entry( - hass: HomeAssistant, - mock_flipr_client: AsyncMock, -) -> None: - """Test migrate config entry from v1 to v2.""" - - mock_config_entry_v1 = MockConfigEntry( - version=1, - domain=DOMAIN, - title="myfliprid", - unique_id="test_entry_unique_id", - data={ - CONF_EMAIL: "toto@toto.com", - CONF_PASSWORD: "myPassword", - "flipr_id": "myfliprid", - }, - ) - - await setup_integration(hass, mock_config_entry_v1) - assert mock_config_entry_v1.state is ConfigEntryState.LOADED - assert mock_config_entry_v1.version == 2 - assert mock_config_entry_v1.unique_id == "toto@toto.com" - assert mock_config_entry_v1.data == { - CONF_EMAIL: "toto@toto.com", - CONF_PASSWORD: "myPassword", - "flipr_id": "myfliprid", - } diff --git a/tests/components/flux_led/test_config_flow.py b/tests/components/flux_led/test_config_flow.py index f486d27244e..14ac4dd23ab 100644 --- a/tests/components/flux_led/test_config_flow.py +++ b/tests/components/flux_led/test_config_flow.py @@ -356,6 +356,60 @@ async def test_manual_working_discovery(hass: HomeAssistant) -> None: assert result2["reason"] == "already_configured" +async def test_user_flow_can_replace_ignored(hass: HomeAssistant) -> None: + """Test a user flow can replace an ignored entry.""" + ignored_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + title=DEFAULT_ENTRY_TITLE, + source=config_entries.SOURCE_IGNORE, + ) + ignored_entry.add_to_hass(hass) + 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" + assert not result["errors"] + + # Cannot connect (timeout) + with _patch_discovery(no_device=True), _patch_wifibulb(no_device=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + # Success + with ( + _patch_discovery(), + _patch_wifibulb(), + patch(f"{MODULE}.async_setup", return_value=True), + patch(f"{MODULE}.async_setup_entry", return_value=True), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["title"] == DEFAULT_ENTRY_TITLE + assert result4["data"] == { + CONF_MINOR_VERSION: 4, + CONF_HOST: IP_ADDRESS, + CONF_MODEL: MODEL, + CONF_MODEL_NUM: MODEL_NUM, + CONF_MODEL_INFO: MODEL, + CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION, + CONF_REMOTE_ACCESS_ENABLED: True, + CONF_REMOTE_ACCESS_HOST: "the.cloud", + CONF_REMOTE_ACCESS_PORT: 8816, + } + + async def test_manual_no_discovery_data(hass: HomeAssistant) -> None: """Test manually setup without discovery data.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index e6adae572f3..abf0153fede 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -16,7 +16,9 @@ from .const import ( DATA_HOME_PIR_GET_VALUE, DATA_HOME_SET_VALUE, DATA_LAN_GET_HOSTS_LIST, + DATA_LAN_GET_HOSTS_LIST_GUEST, DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE, + DATA_LAN_GET_INTERFACES, DATA_STORAGE_GET_DISKS, DATA_STORAGE_GET_RAIDS, DATA_SYSTEM_GET_CONFIG, @@ -68,7 +70,12 @@ def mock_router(mock_device_registry_devices): instance.open = AsyncMock() instance.system.get_config = AsyncMock(return_value=DATA_SYSTEM_GET_CONFIG) # device_tracker - instance.lan.get_hosts_list = AsyncMock(return_value=DATA_LAN_GET_HOSTS_LIST) + instance.lan.get_interfaces = AsyncMock(return_value=DATA_LAN_GET_INTERFACES) + instance.lan.get_hosts_list = AsyncMock( + side_effect=lambda interface: DATA_LAN_GET_HOSTS_LIST + if interface == "pub" + else DATA_LAN_GET_HOSTS_LIST_GUEST + ) # sensor instance.call.get_calls_log = AsyncMock(return_value=DATA_CALL_GET_CALLS_LOG) instance.storage.get_disks = AsyncMock(return_value=DATA_STORAGE_GET_DISKS) @@ -96,6 +103,12 @@ def mock_router(mock_device_registry_devices): def mock_router_bridge_mode(mock_device_registry_devices, router): """Mock a successful connection to Freebox Bridge mode.""" + router().lan.get_interfaces = AsyncMock( + side_effect=HttpRequestError( + f"Request failed (APIResponse: {json.dumps(DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE)})" + ) + ) + router().lan.get_hosts_list = AsyncMock( side_effect=HttpRequestError( f"Request failed (APIResponse: {json.dumps(DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE)})" diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index 5211b793918..47dfac636a7 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -25,7 +25,11 @@ DATA_WIFI_GET_GLOBAL_CONFIG = load_json_object_fixture( ) # device_tracker +DATA_LAN_GET_INTERFACES = load_json_array_fixture("freebox/lan_get_interfaces.json") DATA_LAN_GET_HOSTS_LIST = load_json_array_fixture("freebox/lan_get_hosts_list.json") +DATA_LAN_GET_HOSTS_LIST_GUEST = load_json_array_fixture( + "freebox/lan_get_hosts_list_guest.json" +) DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE = load_json_object_fixture( "freebox/lan_get_hosts_list_bridge.json" ) diff --git a/tests/components/freebox/fixtures/lan_get_hosts_list_guest.json b/tests/components/freebox/fixtures/lan_get_hosts_list_guest.json new file mode 100644 index 00000000000..9e2cdffef0a --- /dev/null +++ b/tests/components/freebox/fixtures/lan_get_hosts_list_guest.json @@ -0,0 +1,81 @@ +[ + { + "l2ident": { + "id": "8C:97:EA:00:00:01", + "type": "mac_address" + }, + "active": true, + "persistent": false, + "names": [ + { + "name": "d633d0c8-958c-42cc-e807-d881b476924b", + "source": "mdns" + }, + { + "name": "Freebox Player POP 2", + "source": "mdns_srv" + } + ], + "vendor_name": "Freebox SAS", + "host_type": "smartphone", + "interface": "pub", + "id": "ether-8c:97:ea:00:00:01", + "last_time_reachable": 1614107662, + "primary_name_manual": false, + "l3connectivities": [ + { + "addr": "192.168.27.181", + "active": true, + "reachable": true, + "last_activity": 1614107614, + "af": "ipv4", + "last_time_reachable": 1614104242 + }, + { + "addr": "fe80::dcef:dbba:6604:31d1", + "active": true, + "reachable": true, + "last_activity": 1614107645, + "af": "ipv6", + "last_time_reachable": 1614107645 + }, + { + "addr": "2a01:e34:eda1:eb40:8102:4704:7ce0:2ace", + "active": false, + "reachable": false, + "last_activity": 1611574428, + "af": "ipv6", + "last_time_reachable": 1611574428 + }, + { + "addr": "2a01:e34:eda1:eb40:c8e5:c524:c96d:5f5e", + "active": false, + "reachable": false, + "last_activity": 1612475101, + "af": "ipv6", + "last_time_reachable": 1612475101 + }, + { + "addr": "2a01:e34:eda1:eb40:583a:49df:1df0:c2df", + "active": true, + "reachable": true, + "last_activity": 1614107652, + "af": "ipv6", + "last_time_reachable": 1614107652 + }, + { + "addr": "2a01:e34:eda1:eb40:147e:3569:86ab:6aaa", + "active": false, + "reachable": false, + "last_activity": 1612486752, + "af": "ipv6", + "last_time_reachable": 1612486752 + } + ], + "default_name": "Freebox Player POP", + "model": "fbx8am", + "reachable": true, + "last_activity": 1614107652, + "primary_name": "Freebox Player POP" + } +] diff --git a/tests/components/freebox/fixtures/lan_get_interfaces.json b/tests/components/freebox/fixtures/lan_get_interfaces.json new file mode 100644 index 00000000000..2646ee38b50 --- /dev/null +++ b/tests/components/freebox/fixtures/lan_get_interfaces.json @@ -0,0 +1,4 @@ +[ + { "name": "pub", "host_count": 4 }, + { "name": "wifiguest", "host_count": 1 } +] diff --git a/tests/components/freebox/test_device_tracker.py b/tests/components/freebox/test_device_tracker.py index 405166d6ba2..f0821daabc3 100644 --- a/tests/components/freebox/test_device_tracker.py +++ b/tests/components/freebox/test_device_tracker.py @@ -21,14 +21,14 @@ async def test_router_mode( """Test get_hosts_list invoqued multiple times if freebox into router mode.""" await setup_platform(hass, DEVICE_TRACKER_DOMAIN) - assert router().lan.get_hosts_list.call_count == 1 + assert router().lan.get_hosts_list.call_count == 2 # Simulate an update freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert router().lan.get_hosts_list.call_count == 2 + assert router().lan.get_hosts_list.call_count == 4 async def test_bridge_mode( @@ -36,15 +36,15 @@ async def test_bridge_mode( freezer: FrozenDateTimeFactory, router_bridge_mode: Mock, ) -> None: - """Test get_hosts_list invoqued once if freebox into bridge mode.""" + """Test get_interfaces invoqued once if freebox into bridge mode.""" await setup_platform(hass, DEVICE_TRACKER_DOMAIN) - assert router_bridge_mode().lan.get_hosts_list.call_count == 1 + assert router_bridge_mode().lan.get_interfaces.call_count == 1 # Simulate an update freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - # If get_hosts_list failed, not called again - assert router_bridge_mode().lan.get_hosts_list.call_count == 1 + # If get_interfaces failed, not called again + assert router_bridge_mode().lan.get_interfaces.call_count == 1 diff --git a/tests/components/freebox/test_router.py b/tests/components/freebox/test_router.py index 623f595e1ad..3d98abf71a2 100644 --- a/tests/components/freebox/test_router.py +++ b/tests/components/freebox/test_router.py @@ -35,7 +35,10 @@ async def test_get_hosts_list_if_supported( assert supports_hosts is True # List must not be empty; but it's content depends on how many unit tests are executed... assert fbx_devices + # We expect 4 devices from lan_get_hosts_list.json and 1 from lan_get_hosts_list_guest.json + assert len(fbx_devices) == 5 assert "d633d0c8-958c-43cc-e807-d881b076924b" in str(fbx_devices) + assert "d633d0c8-958c-42cc-e807-d881b476924b" in str(fbx_devices) async def test_get_hosts_list_if_supported_bridge( diff --git a/tests/components/freedns/test_init.py b/tests/components/freedns/test_init.py index d142fd767e1..eab0a1793ce 100644 --- a/tests/components/freedns/test_init.py +++ b/tests/components/freedns/test_init.py @@ -16,7 +16,9 @@ UPDATE_URL = freedns.UPDATE_URL @pytest.fixture -def setup_freedns(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def setup_freedns( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Fixture that sets up FreeDNS.""" params = {} params[ACCESS_TOKEN] = "" @@ -24,17 +26,15 @@ def setup_freedns(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> N UPDATE_URL, params=params, text="Successfully updated 1 domains." ) - hass.loop.run_until_complete( - async_setup_component( - hass, - freedns.DOMAIN, - { - freedns.DOMAIN: { - "access_token": ACCESS_TOKEN, - "scan_interval": UPDATE_INTERVAL, - } - }, - ) + await async_setup_component( + hass, + freedns.DOMAIN, + { + freedns.DOMAIN: { + "access_token": ACCESS_TOKEN, + "scan_interval": UPDATE_INTERVAL, + } + }, ) diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py index 1e292ed22bb..c1908c12a14 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -200,6 +200,7 @@ MOCK_FB_SERVICES: dict[str, dict] = { MOCK_IPS["printer"]: {"NewDisallow": False, "NewWANAccess": "granted"} } }, + "X_AVM-DE_UPnP1": {"GetInfo": {"NewEnable": True}}, } MOCK_MESH_DATA = { diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr index 9b5b8c9353a..c2ca866ceb6 100644 --- a/tests/components/fritz/snapshots/test_diagnostics.ambr +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -27,6 +27,7 @@ 'WLANConfiguration1', 'X_AVM-DE_Homeauto1', 'X_AVM-DE_HostFilter1', + 'X_AVM-DE_UPnP1', ]), 'is_router': True, 'last_exception': None, diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index f4c4229af74..ee3ae881b2c 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for Fritz!Tools config flow.""" +from copy import deepcopy import dataclasses from unittest.mock import patch @@ -20,6 +21,7 @@ from homeassistant.components.fritz.const import ( ERROR_AUTH_INVALID, ERROR_CANNOT_CONNECT, ERROR_UNKNOWN, + ERROR_UPNP_NOT_CONFIGURED, FRITZ_AUTH_EXCEPTIONS, ) from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER @@ -38,7 +40,9 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) +from .conftest import FritzConnectionMock from .const import ( + MOCK_FB_SERVICES, MOCK_FIRMWARE_INFO, MOCK_IPS, MOCK_REQUEST, @@ -761,3 +765,54 @@ async def test_ssdp_ipv6_link_local(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "ignore_ip6_link_local" + + +async def test_upnp_not_enabled(hass: HomeAssistant) -> None: + """Test if UPNP service is enabled on the router.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Disable UPnP + services = deepcopy(MOCK_FB_SERVICES) + services["X_AVM-DE_UPnP1"]["GetInfo"]["NewEnable"] = False + + with patch( + "homeassistant.components.fritz.config_flow.FritzConnection", + return_value=FritzConnectionMock(services), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT_SIMPLE + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == ERROR_UPNP_NOT_CONFIGURED + + # Enable UPnP + services["X_AVM-DE_UPnP1"]["GetInfo"]["NewEnable"] = True + + with ( + patch( + "homeassistant.components.fritz.config_flow.FritzConnection", + return_value=FritzConnectionMock(services), + ), + patch( + "homeassistant.components.fritz.config_flow.socket.gethostbyname", + return_value=MOCK_IPS["fritz.box"], + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT_SIMPLE + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == "fake_host" + assert result["data"][CONF_PASSWORD] == "fake_pass" + assert result["data"][CONF_USERNAME] == "fake_user" + assert result["data"][CONF_PORT] == 49000 + assert result["data"][CONF_SSL] is False diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 034b86497db..5792ccf85b1 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -25,7 +25,7 @@ async def setup_config_entry( device: Mock | None = None, fritz: Mock | None = None, template: Mock | None = None, -) -> bool: +) -> MockConfigEntry: """Do setup of a MockConfigEntry.""" entry = MockConfigEntry( domain=DOMAIN, @@ -39,10 +39,10 @@ async def setup_config_entry( if template is not None and fritz is not None: fritz().get_templates.return_value = [template] - result = await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(entry.entry_id) if device is not None: await hass.async_block_till_done() - return result + return entry def set_devices( @@ -60,6 +60,7 @@ class FritzEntityBaseMock(Mock): """base mock of a AVM Fritz!Box binary sensor device.""" ain = CONF_FAKE_AIN + device_and_unit_id = (CONF_FAKE_AIN, None) manufacturer = CONF_FAKE_MANUFACTURER name = CONF_FAKE_NAME productname = CONF_FAKE_PRODUCTNAME diff --git a/tests/components/fritzbox/snapshots/test_binary_sensor.ambr b/tests/components/fritzbox/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..1d645947ceb --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_binary_sensor.ambr @@ -0,0 +1,334 @@ +# serializer version: 1 +# name: test_setup[binary_sensor.fake_name_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.fake_name_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': 'Alarm', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarm', + 'unique_id': '12345 1234567_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'fake_name Alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup[binary_sensor.fake_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_battery_low', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'fake_name Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_on_device-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.fake_name_button_lock_on_device', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button lock on device', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': '12345 1234567_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_on_device-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'fake_name Button lock on device', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_button_lock_on_device', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_via_ui-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.fake_name_button_lock_via_ui', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button lock via UI', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_lock', + 'unique_id': '12345 1234567_device_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_via_ui-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'fake_name Button lock via UI', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_button_lock_via_ui', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup[binary_sensor.fake_name_holiday_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.fake_name_holiday_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': 'Holiday mode', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'holiday_active', + 'unique_id': '12345 1234567_holiday_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_holiday_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name Holiday mode', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_holiday_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup[binary_sensor.fake_name_open_window_detected-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.fake_name_open_window_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Open window detected', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'window_open', + 'unique_id': '12345 1234567_window_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_open_window_detected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name Open window detected', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_open_window_detected', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup[binary_sensor.fake_name_summer_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.fake_name_summer_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': 'Summer mode', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'summer_active', + 'unique_id': '12345 1234567_summer_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_summer_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name Summer mode', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_summer_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_button.ambr b/tests/components/fritzbox/snapshots/test_button.ambr new file mode 100644 index 00000000000..95e757da3cc --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_button.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_setup[button.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[button.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name', + }), + 'context': , + 'entity_id': 'button.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_climate.ambr b/tests/components/fritzbox/snapshots/test_climate.ambr new file mode 100644 index 00000000000..26e06105152 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_climate.ambr @@ -0,0 +1,80 @@ +# serializer version: 1 +# name: test_setup[climate.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 8.0, + 'preset_modes': list([ + 'eco', + 'comfort', + 'boost', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'thermostat', + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[climate.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_level': 23, + 'battery_low': True, + 'current_temperature': 18.0, + 'friendly_name': 'fake_name', + 'holiday_mode': False, + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 8.0, + 'preset_mode': None, + 'preset_modes': list([ + 'eco', + 'comfort', + 'boost', + ]), + 'summer_mode': False, + 'supported_features': , + 'temperature': 19.5, + 'window_open': 'fake_window', + }), + 'context': , + 'entity_id': 'climate.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_cover.ambr b/tests/components/fritzbox/snapshots/test_cover.ambr new file mode 100644 index 00000000000..ce6b305e154 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_cover.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_setup[cover.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[cover.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'blind', + 'friendly_name': 'fake_name', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_light.ambr b/tests/components/fritzbox/snapshots/test_light.ambr new file mode 100644 index 00000000000..f6f4516bdec --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_light.ambr @@ -0,0 +1,278 @@ +# serializer version: 1 +# name: test_setup[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 100, + 'color_mode': , + 'color_temp': 370, + 'color_temp_kelvin': 2700, + 'friendly_name': 'fake_name', + 'hs_color': tuple( + 28.395, + 65.723, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 167, + 87, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.525, + 0.388, + ), + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_color[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_color[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 100, + 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'fake_name', + 'hs_color': tuple( + 100, + 70.0, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'rgb_color': tuple( + 136, + 255, + 77, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.271, + 0.609, + ), + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_non_color[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_non_color[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 100, + 'color_mode': , + 'friendly_name': 'fake_name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_non_color_non_level[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_non_color_non_level[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'fake_name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_sensor.ambr b/tests/components/fritzbox/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..68f8e161d07 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_sensor.ambr @@ -0,0 +1,810 @@ +# serializer version: 1 +# name: test_setup[FritzDeviceBinarySensorMock][sensor.fake_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceBinarySensorMock][sensor.fake_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'fake_name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'fake_name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_comfort_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_comfort_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': 'Comfort temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'comfort_temperature', + 'unique_id': '12345 1234567_comfort_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_comfort_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Comfort temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_comfort_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_current_scheduled_preset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_current_scheduled_preset', + '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 scheduled preset', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'scheduled_preset', + 'unique_id': '12345 1234567_scheduled_preset', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_current_scheduled_preset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name Current scheduled preset', + }), + 'context': , + 'entity_id': 'sensor.fake_name_current_scheduled_preset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'eco', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_eco_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_eco_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': 'Eco temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'eco_temperature', + 'unique_id': '12345 1234567_eco_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_eco_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Eco temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_eco_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.0', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_change_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_next_scheduled_change_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next scheduled change time', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nextchange_time', + 'unique_id': '12345 1234567_nextchange_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_change_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'fake_name Next scheduled change time', + }), + 'context': , + 'entity_id': 'sensor.fake_name_next_scheduled_change_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-09-20T18:00:00+00:00', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_preset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_next_scheduled_preset', + '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 scheduled preset', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nextchange_preset', + 'unique_id': '12345 1234567_nextchange_preset', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_preset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name Next scheduled preset', + }), + 'context': , + 'entity_id': 'sensor.fake_name_next_scheduled_preset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'comfort', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_next_scheduled_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': 'Next scheduled temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nextchange_temperature', + 'unique_id': '12345 1234567_nextchange_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Next scheduled temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_next_scheduled_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'fake_name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'fake_name Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_electric_current', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'fake_name Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.025', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_total_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'fake_name Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.234', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_power_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'fake_name Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.678', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'fake_name Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_switch.ambr b/tests/components/fritzbox/snapshots/test_switch.ambr new file mode 100644 index 00000000000..23deb8183fc --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_switch.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_setup[switch.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[switch.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name', + }), + 'context': , + 'entity_id': 'switch.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index d5b0b5d196b..3eac2c24953 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -2,87 +2,51 @@ from datetime import timedelta from unittest import mock -from unittest.mock import Mock +from unittest.mock import Mock, patch +import pytest from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion -from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, - BinarySensorDeviceClass, -) +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, - CONF_DEVICES, - PERCENTAGE, - STATE_OFF, - STATE_ON, - STATE_UNAVAILABLE, -) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICES, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import FritzDeviceBinarySensorMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{BINARY_SENSOR_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceBinarySensorMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.BINARY_SENSOR]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(f"{ENTITY_ID}_alarm") - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Alarm" - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.WINDOW - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{ENTITY_ID}_button_lock_on_device") - assert state - assert state.state == STATE_OFF - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Button lock on device" - ) - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.LOCK - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{ENTITY_ID}_button_lock_via_ui") - assert state - assert state.state == STATE_OFF - assert ( - state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Button lock via UI" - ) - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.LOCK - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_battery") - assert state - assert state.state == "23" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_is_off(hass: HomeAssistant, fritz: Mock) -> None: """Test state of platform.""" device = FritzDeviceBinarySensorMock() device.present = False - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -102,7 +66,7 @@ async def test_is_off(hass: HomeAssistant, fritz: Mock) -> None: async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceBinarySensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -121,7 +85,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceBinarySensorMock() device.update.side_effect = [mock.DEFAULT, HTTPError("Boom")] - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -139,7 +103,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceBinarySensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -148,6 +112,7 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: new_device = FritzDeviceBinarySensorMock() new_device.ain = "7890 1234" + new_device.device_and_unit_id = ("7890 1234", None) new_device.name = "new_device" set_devices(fritz, devices=[device, new_device]) diff --git a/tests/components/fritzbox/test_button.py b/tests/components/fritzbox/test_button.py index 0053a8d3446..5280cd7cc83 100644 --- a/tests/components/fritzbox/test_button.py +++ b/tests/components/fritzbox/test_button.py @@ -1,44 +1,50 @@ """Tests for AVM Fritz!Box templates.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, patch + +from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - CONF_DEVICES, - STATE_UNKNOWN, -) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICES, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import FritzEntityBaseMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{BUTTON_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test if is initialized correctly.""" template = FritzEntityBaseMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.BUTTON]): + entry = await setup_config_entry( + hass, + MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], + fritz=fritz, + template=template, + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME - assert state.state == STATE_UNKNOWN + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_apply_template(hass: HomeAssistant, fritz: Mock) -> None: """Test if applies works.""" template = FritzEntityBaseMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template ) @@ -51,7 +57,7 @@ async def test_apply_template(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" template = FritzEntityBaseMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template ) diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 699a2b8c53e..bdf9dba8b42 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -1,11 +1,12 @@ """Tests for AVM Fritz!Box climate component.""" from datetime import timedelta -from unittest.mock import Mock, _Call, call +from unittest.mock import Mock, _Call, call, patch from freezegun.api import FrozenDateTimeFactory import pytest from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -31,29 +32,15 @@ from homeassistant.components.fritzbox.climate import ( PRESET_SUMMER, ) from homeassistant.components.fritzbox.const import ( - ATTR_STATE_BATTERY_LOW, ATTR_STATE_HOLIDAY_MODE, ATTR_STATE_SUMMER_MODE, - ATTR_STATE_WINDOW_OPEN, DOMAIN as FB_DOMAIN, ) -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT, - CONF_DEVICES, - PERCENTAGE, - UnitOfTemperature, -) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_DEVICES, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import ( @@ -64,127 +51,31 @@ from . import ( ) from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{CLIMATE_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.CLIMATE]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) - state = hass.states.get(ENTITY_ID) - assert state - assert state.attributes[ATTR_BATTERY_LEVEL] == 23 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 18 - assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] - assert state.attributes[ATTR_MAX_TEMP] == 28 - assert state.attributes[ATTR_MIN_TEMP] == 8 - assert state.attributes[ATTR_PRESET_MODE] is None - assert state.attributes[ATTR_PRESET_MODES] == [ - PRESET_ECO, - PRESET_COMFORT, - PRESET_BOOST, - ] - assert state.attributes[ATTR_STATE_BATTERY_LOW] is True - assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False - assert state.attributes[ATTR_STATE_SUMMER_MODE] is False - assert state.attributes[ATTR_STATE_WINDOW_OPEN] == "fake_window" - assert state.attributes[ATTR_TEMPERATURE] == 19.5 - assert ATTR_STATE_CLASS not in state.attributes - assert state.state == HVACMode.HEAT - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_battery") - assert state - assert state.state == "23" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_comfort_temperature") - assert state - assert state.state == "22.0" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Comfort temperature" - ) - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_eco_temperature") - assert state - assert state.state == "16.0" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Eco temperature" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_temperature" - ) - assert state - assert state.state == "22.0" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Next scheduled temperature" - ) - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_change_time" - ) - assert state - assert state.state == "2024-09-20T18:00:00+00:00" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Next scheduled change time" - ) - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_preset") - assert state - assert state.state == PRESET_COMFORT - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Next scheduled preset" - ) - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current_scheduled_preset" - ) - assert state - assert state.state == PRESET_ECO - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Current scheduled preset" - ) - assert ATTR_STATE_CLASS not in state.attributes - - device.nextchange_temperature = 16 - - next_update = dt_util.utcnow() + timedelta(seconds=200) - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done(wait_background_tasks=True) - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_preset") - assert state - assert state.state == PRESET_ECO - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current_scheduled_preset" - ) - assert state - assert state.state == PRESET_COMFORT + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_hkr_wo_temperature_sensor(hass: HomeAssistant, fritz: Mock) -> None: """Test hkr without exposing dedicated temperature sensor data block.""" device = FritzDeviceClimateWithoutTempSensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -197,32 +88,32 @@ async def test_target_temperature_on(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device on.""" device = FritzDeviceClimateMock() device.target_temperature = 127.0 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) state = hass.states.get(ENTITY_ID) assert state - assert state.attributes[ATTR_TEMPERATURE] == 30 + assert state.attributes[ATTR_TEMPERATURE] is None async def test_target_temperature_off(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device on.""" device = FritzDeviceClimateMock() device.target_temperature = 126.5 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) state = hass.states.get(ENTITY_ID) assert state - assert state.attributes[ATTR_TEMPERATURE] == 0 + assert state.attributes[ATTR_TEMPERATURE] is None async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -253,7 +144,7 @@ async def test_automatic_offset(hass: HomeAssistant, fritz: Mock) -> None: device.temperature = 18 device.actual_temperature = 19 device.target_temperature = 20 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -269,9 +160,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceClimateMock() fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 @@ -285,15 +177,20 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: @pytest.mark.parametrize( - ("service_data", "expected_call_args"), + ( + "service_data", + "expected_set_target_temperature_call_args", + "expected_set_hkr_state_call_args", + ), [ - ({ATTR_TEMPERATURE: 23}, [call(23, True)]), + ({ATTR_TEMPERATURE: 23}, [call(23, True)], []), ( { ATTR_HVAC_MODE: HVACMode.OFF, ATTR_TEMPERATURE: 23, }, - [call(0, True)], + [], + [call("off", True)], ), ( { @@ -301,6 +198,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: ATTR_TEMPERATURE: 23, }, [call(23, True)], + [], ), ], ) @@ -308,11 +206,14 @@ async def test_set_temperature( hass: HomeAssistant, fritz: Mock, service_data: dict, - expected_call_args: list[_Call], + expected_set_target_temperature_call_args: list[_Call], + expected_set_hkr_state_call_args: list[_Call], ) -> None: """Test setting temperature.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + device.lock = False + + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -322,29 +223,60 @@ async def test_set_temperature( {ATTR_ENTITY_ID: ENTITY_ID, **service_data}, True, ) - assert device.set_target_temperature.call_count == len(expected_call_args) - assert device.set_target_temperature.call_args_list == expected_call_args + assert device.set_target_temperature.call_count == len( + expected_set_target_temperature_call_args + ) + assert ( + device.set_target_temperature.call_args_list + == expected_set_target_temperature_call_args + ) + assert device.set_hkr_state.call_count == len(expected_set_hkr_state_call_args) + assert device.set_hkr_state.call_args_list == expected_set_hkr_state_call_args @pytest.mark.parametrize( - ("service_data", "target_temperature", "current_preset", "expected_call_args"), + ( + "service_data", + "target_temperature", + "current_preset", + "expected_set_target_temperature_call_args", + "expected_set_hkr_state_call_args", + ), [ - # mode off always sets target temperature to 0 - ({ATTR_HVAC_MODE: HVACMode.OFF}, 22, PRESET_COMFORT, [call(0, True)]), - ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, PRESET_ECO, [call(0, True)]), - ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, None, [call(0, True)]), + # mode off always sets hkr state off + ({ATTR_HVAC_MODE: HVACMode.OFF}, 22, PRESET_COMFORT, [], [call("off", True)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, PRESET_ECO, [], [call("off", True)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, None, [], [call("off", True)]), # mode heat sets target temperature based on current scheduled preset, # when not already in mode heat - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_COMFORT, [call(22, True)]), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_ECO, [call(16, True)]), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, None, [call(22, True)]), + ( + {ATTR_HVAC_MODE: HVACMode.HEAT}, + OFF_API_TEMPERATURE, + PRESET_COMFORT, + [call(22, True)], + [], + ), + ( + {ATTR_HVAC_MODE: HVACMode.HEAT}, + OFF_API_TEMPERATURE, + PRESET_ECO, + [call(16, True)], + [], + ), + ( + {ATTR_HVAC_MODE: HVACMode.HEAT}, + OFF_API_TEMPERATURE, + None, + [call(22, True)], + [], + ), # mode heat does not set target temperature, when already in mode heat - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_COMFORT, []), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_ECO, []), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, None, []), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, PRESET_COMFORT, []), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, PRESET_ECO, []), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, None, []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_COMFORT, [], []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_ECO, [], []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, None, [], []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, PRESET_COMFORT, [], []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, PRESET_ECO, [], []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, None, [], []), ], ) async def test_set_hvac_mode( @@ -353,10 +285,13 @@ async def test_set_hvac_mode( service_data: dict, target_temperature: float, current_preset: str, - expected_call_args: list[_Call], + expected_set_target_temperature_call_args: list[_Call], + expected_set_hkr_state_call_args: list[_Call], ) -> None: """Test setting hvac mode.""" device = FritzDeviceClimateMock() + + device.lock = False device.target_temperature = target_temperature if current_preset is PRESET_COMFORT: @@ -366,7 +301,7 @@ async def test_set_hvac_mode( else: device.nextchange_endperiod = 0 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -377,16 +312,23 @@ async def test_set_hvac_mode( True, ) - assert device.set_target_temperature.call_count == len(expected_call_args) - assert device.set_target_temperature.call_args_list == expected_call_args + assert device.set_target_temperature.call_count == len( + expected_set_target_temperature_call_args + ) + assert ( + device.set_target_temperature.call_args_list + == expected_set_target_temperature_call_args + ) + assert device.set_hkr_state.call_count == len(expected_set_hkr_state_call_args) + assert device.set_hkr_state.call_args_list == expected_set_hkr_state_call_args @pytest.mark.parametrize( ("comfort_temperature", "expected_call_args"), [ - (20, [call(20, True)]), - (28, [call(28, True)]), - (ON_API_TEMPERATURE, [call(30, True)]), + (20, [call("comfort", True)]), + (28, [call("comfort", True)]), + (ON_API_TEMPERATURE, [call("comfort", True)]), ], ) async def test_set_preset_mode_comfort( @@ -397,8 +339,10 @@ async def test_set_preset_mode_comfort( ) -> None: """Test setting preset mode.""" device = FritzDeviceClimateMock() + + device.lock = False device.comfort_temperature = comfort_temperature - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -408,16 +352,16 @@ async def test_set_preset_mode_comfort( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_COMFORT}, True, ) - assert device.set_target_temperature.call_count == len(expected_call_args) - assert device.set_target_temperature.call_args_list == expected_call_args + assert device.set_hkr_state.call_count == len(expected_call_args) + assert device.set_hkr_state.call_args_list == expected_call_args @pytest.mark.parametrize( ("eco_temperature", "expected_call_args"), [ - (20, [call(20, True)]), - (16, [call(16, True)]), - (OFF_API_TEMPERATURE, [call(0, True)]), + (20, [call("eco", True)]), + (16, [call("eco", True)]), + (OFF_API_TEMPERATURE, [call("eco", True)]), ], ) async def test_set_preset_mode_eco( @@ -428,8 +372,10 @@ async def test_set_preset_mode_eco( ) -> None: """Test setting preset mode.""" device = FritzDeviceClimateMock() + + device.lock = False device.eco_temperature = eco_temperature - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -439,8 +385,8 @@ async def test_set_preset_mode_eco( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO}, True, ) - assert device.set_target_temperature.call_count == len(expected_call_args) - assert device.set_target_temperature.call_args_list == expected_call_args + assert device.set_hkr_state.call_count == len(expected_call_args) + assert device.set_hkr_state.call_args_list == expected_call_args async def test_set_preset_mode_boost( @@ -449,7 +395,9 @@ async def test_set_preset_mode_boost( ) -> None: """Test setting preset mode.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + device.lock = False + + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -459,8 +407,8 @@ async def test_set_preset_mode_boost( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_BOOST}, True, ) - assert device.set_target_temperature.call_count == 1 - assert device.set_target_temperature.call_args_list == [call(30, True)] + assert device.set_hkr_state.call_count == 1 + assert device.set_hkr_state.call_args_list == [call("on", True)] async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: @@ -468,7 +416,7 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: device = FritzDeviceClimateMock() device.comfort_temperature = 23 device.eco_temperature = 20 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -513,7 +461,7 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -533,12 +481,107 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: assert state +@pytest.mark.parametrize( + "service_data", + [ + {ATTR_TEMPERATURE: 23}, + { + ATTR_HVAC_MODE: HVACMode.HEAT, + ATTR_TEMPERATURE: 25, + }, + ], +) +async def test_set_temperature_lock( + hass: HomeAssistant, + fritz: Mock, + service_data: dict, +) -> None: + """Test setting temperature while device is locked.""" + device = FritzDeviceClimateMock() + + device.lock = True + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + with pytest.raises( + HomeAssistantError, + match="Can't change settings while manual access for telephone, app, or user interface is disabled on the device", + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, **service_data}, + True, + ) + + +@pytest.mark.parametrize( + ("service_data", "target_temperature", "current_preset", "expected_call_args"), + [ + # mode off always sets target temperature to 0 + ({ATTR_HVAC_MODE: HVACMode.OFF}, 22, PRESET_COMFORT, [call(0, True)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, PRESET_ECO, [call(0, True)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, None, [call(0, True)]), + # mode heat sets target temperature based on current scheduled preset, + # when not already in mode heat + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_COMFORT, [call(22, True)]), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_ECO, [call(16, True)]), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, None, [call(22, True)]), + # mode heat does not set target temperature, when already in mode heat + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_COMFORT, []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_ECO, []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, None, []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, PRESET_COMFORT, []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, PRESET_ECO, []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, None, []), + ], +) +async def test_set_hvac_mode_lock( + hass: HomeAssistant, + fritz: Mock, + service_data: dict, + target_temperature: float, + current_preset: str, + expected_call_args: list[_Call], +) -> None: + """Test setting hvac mode while device is locked.""" + device = FritzDeviceClimateMock() + + device.lock = True + device.target_temperature = target_temperature + + if current_preset is PRESET_COMFORT: + device.nextchange_temperature = device.eco_temperature + elif current_preset is PRESET_ECO: + device.nextchange_temperature = device.comfort_temperature + else: + device.nextchange_endperiod = 0 + + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + with pytest.raises( + HomeAssistantError, + match="Can't change settings while manual access for telephone, app, or user interface is disabled on the device", + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, **service_data}, + True, + ) + + async def test_holidy_summer_mode( hass: HomeAssistant, freezer: FrozenDateTimeFactory, fritz: Mock ) -> None: """Test holiday and summer mode.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + device.lock = False + + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -572,7 +615,7 @@ async def test_holidy_summer_mode( with pytest.raises( HomeAssistantError, - match="Can't change HVAC mode while holiday or summer mode is active on the device", + match="Can't change settings while holiday or summer mode is active on the device", ): await hass.services.async_call( "climate", @@ -582,7 +625,7 @@ async def test_holidy_summer_mode( ) with pytest.raises( HomeAssistantError, - match="Can't change preset while holiday or summer mode is active on the device", + match="Can't change settings while holiday or summer mode is active on the device", ): await hass.services.async_call( "climate", @@ -608,7 +651,7 @@ async def test_holidy_summer_mode( with pytest.raises( HomeAssistantError, - match="Can't change HVAC mode while holiday or summer mode is active on the device", + match="Can't change settings while holiday or summer mode is active on the device", ): await hass.services.async_call( "climate", @@ -618,7 +661,7 @@ async def test_holidy_summer_mode( ) with pytest.raises( HomeAssistantError, - match="Can't change preset while holiday or summer mode is active on the device", + match="Can't change settings while holiday or summer mode is active on the device", ): await hass.services.async_call( "climate", diff --git a/tests/components/fritzbox/test_coordinator.py b/tests/components/fritzbox/test_coordinator.py index 401fab8f169..f4f4da90181 100644 --- a/tests/components/fritzbox/test_coordinator.py +++ b/tests/components/fritzbox/test_coordinator.py @@ -85,8 +85,16 @@ async def test_coordinator_automatic_registry_cleanup( ) -> None: """Test automatic registry cleanup.""" fritz().get_devices.return_value = [ - FritzDeviceSwitchMock(ain="fake ain switch", name="fake_switch"), - FritzDeviceCoverMock(ain="fake ain cover", name="fake_cover"), + FritzDeviceSwitchMock( + ain="fake ain switch", + device_and_unit_id=("fake ain switch", None), + name="fake_switch", + ), + FritzDeviceCoverMock( + ain="fake ain cover", + device_and_unit_id=("fake ain cover", None), + name="fake_cover", + ), ] entry = MockConfigEntry( domain=FB_DOMAIN, @@ -97,15 +105,19 @@ async def test_coordinator_automatic_registry_cleanup( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) - assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 11 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 19 assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 2 fritz().get_devices.return_value = [ - FritzDeviceSwitchMock(ain="fake ain switch", name="fake_switch") + FritzDeviceSwitchMock( + ain="fake ain switch", + device_and_unit_id=("fake ain switch", None), + name="fake_switch", + ) ] async_fire_time_changed(hass, utcnow() + timedelta(seconds=35)) await hass.async_block_till_done(wait_background_tasks=True) - assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 8 + 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)) == 1 diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py index 535306e4ef2..a1332e9715b 100644 --- a/tests/components/fritzbox/test_cover.py +++ b/tests/components/fritzbox/test_cover.py @@ -1,15 +1,13 @@ """Tests for AVM Fritz!Box switch component.""" from datetime import timedelta -from unittest.mock import Mock, call +from unittest.mock import Mock, call, patch -from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, - ATTR_POSITION, - DOMAIN as COVER_DOMAIN, - CoverState, -) +from syrupy import SnapshotAssertion + +from homeassistant.components.cover import ATTR_POSITION, DOMAIN as COVER_DOMAIN from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICES, @@ -18,8 +16,10 @@ from homeassistant.const import ( SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import ( @@ -30,28 +30,32 @@ from . import ( ) from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{COVER_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.COVER]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == CoverState.OPEN - assert state.attributes[ATTR_CURRENT_POSITION] == 100 + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_unknown_position(hass: HomeAssistant, fritz: Mock) -> None: """Test cover with unknown position.""" device = FritzDeviceCoverUnknownPositionMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -63,7 +67,7 @@ async def test_unknown_position(hass: HomeAssistant, fritz: Mock) -> None: async def test_open_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test opening the cover.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -76,7 +80,7 @@ async def test_open_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_close_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test closing the device.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -89,7 +93,7 @@ async def test_close_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_set_position_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test stopping the device.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -105,7 +109,7 @@ async def test_set_position_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test stopping the device.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -118,7 +122,7 @@ async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index fe8bb32066e..d9a81bf8f21 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -1,9 +1,10 @@ """Tests for AVM Fritz!Box light component.""" from datetime import timedelta -from unittest.mock import Mock, call +from unittest.mock import Mock, call, patch from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion from homeassistant.components.fritzbox.const import ( COLOR_MODE, @@ -12,35 +13,36 @@ from homeassistant.components.fritzbox.const import ( ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_MAX_COLOR_TEMP_KELVIN, - ATTR_MIN_COLOR_TEMP_KELVIN, - ATTR_SUPPORTED_COLOR_MODES, DOMAIN as LIGHT_DOMAIN, - ColorMode, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, CONF_DEVICES, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import FritzDeviceLightMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{LIGHT_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceLightMock() device.get_color_temps.return_value = [2700, 6500] @@ -50,42 +52,42 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: device.color_mode = COLOR_TEMP_MODE device.color_temp = 2700 - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 2700 - assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 - assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 - assert state.attributes[ATTR_HS_COLOR] == (28.395, 65.723) - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_setup_non_color(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup_non_color( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform of non color bulb.""" device = FritzDeviceLightMock() device.has_color = False device.get_color_temps.return_value = [] device.get_colors.return_value = {} - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_BRIGHTNESS] == 100 - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness"] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_setup_non_color_non_level(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup_non_color_non_level( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform of non color and non level bulb.""" device = FritzDeviceLightMock() device.has_color = False @@ -93,22 +95,21 @@ async def test_setup_non_color_non_level(hass: HomeAssistant, fritz: Mock) -> No device.get_color_temps.return_value = [] device.get_colors.return_value = {} - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert ATTR_BRIGHTNESS not in state.attributes - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] - assert state.attributes[ATTR_COLOR_MODE] == ColorMode.ONOFF - assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) is None - assert state.attributes.get(ATTR_HS_COLOR) is None + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_setup_color(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup_color( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform in color mode.""" device = FritzDeviceLightMock() device.get_color_temps.return_value = [2700, 6500] @@ -119,19 +120,13 @@ async def test_setup_color(hass: HomeAssistant, fritz: Mock) -> None: device.hue = 100 device.saturation = 70 * 255.0 / 100.0 - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_COLOR_MODE] == ColorMode.HS - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] is None - assert state.attributes[ATTR_BRIGHTNESS] == 100 - assert state.attributes[ATTR_HS_COLOR] == (100, 70) - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: @@ -258,9 +253,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] } fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index cb136eee993..7912aaf8d12 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -1,97 +1,69 @@ """Tests for AVM Fritz!Box sensor component.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion from homeassistant.components.climate import PRESET_COMFORT, PRESET_ECO from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, - CONF_DEVICES, - PERCENTAGE, - STATE_UNKNOWN, - EntityCategory, - UnitOfTemperature, -) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICES, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import ( + FritzDeviceBinarySensorMock, FritzDeviceClimateMock, FritzDeviceSensorMock, + FritzDeviceSwitchMock, + FritzEntityBaseMock, set_devices, setup_config_entry, ) from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}" +@pytest.mark.parametrize( + "device", + [ + FritzDeviceBinarySensorMock, + FritzDeviceClimateMock, + FritzDeviceSensorMock, + FritzDeviceSwitchMock, + ], +) async def test_setup( - hass: HomeAssistant, entity_registry: er.EntityRegistry, fritz: Mock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, + device: FritzEntityBaseMock, ) -> None: - """Test setup of platform.""" - device = FritzDeviceSensorMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) - await hass.async_block_till_done() + """Test setup of sensor platform for different device types.""" + device = device() - sensors = ( - [ - f"{ENTITY_ID}_temperature", - "1.23", - f"{CONF_FAKE_NAME} Temperature", - UnitOfTemperature.CELSIUS, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{ENTITY_ID}_humidity", - "42", - f"{CONF_FAKE_NAME} Humidity", - PERCENTAGE, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{ENTITY_ID}_battery", - "23", - f"{CONF_FAKE_NAME} Battery", - PERCENTAGE, - SensorStateClass.MEASUREMENT, - EntityCategory.DIAGNOSTIC, - ], - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.SENSOR]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - for sensor in sensors: - state = hass.states.get(sensor[0]) - assert state - assert state.state == sensor[1] - assert state.attributes[ATTR_FRIENDLY_NAME] == sensor[2] - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == sensor[3] - assert state.attributes.get(ATTR_STATE_CLASS) == sensor[4] - entry = entity_registry.async_get(sensor[0]) - assert entry - assert entry.entity_category is sensor[5] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceSensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) assert fritz().update_devices.call_count == 1 @@ -109,9 +81,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceSensorMock() fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 @@ -126,7 +99,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceSensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -135,6 +108,7 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: new_device = FritzDeviceSensorMock() new_device.ain = "7890 1234" + new_device.device_and_unit_id = ("7890 1234", None) new_device.name = "new_device" set_devices(fritz, devices=[device, new_device]) @@ -175,7 +149,7 @@ async def test_next_change_sensors( device.nextchange_endperiod = next_changes[0] device.nextchange_temperature = next_changes[1] - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 511725c663f..cb6b563d344 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -1,33 +1,22 @@ """Tests for AVM Fritz!Box switch component.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_ON, STATE_UNAVAILABLE, - EntityCategory, - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfPower, - UnitOfTemperature, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -37,89 +26,32 @@ from homeassistant.util import dt as dt_util from . import FritzDeviceSwitchMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{SWITCH_DOMAIN}.{CONF_FAKE_NAME}" async def test_setup( - hass: HomeAssistant, entity_registry: er.EntityRegistry, fritz: Mock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, ) -> None: """Test setup of platform.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.SWITCH]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{ENTITY_ID}_humidity") - assert state is None - - sensors = ( - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_temperature", - "1.23", - f"{CONF_FAKE_NAME} Temperature", - UnitOfTemperature.CELSIUS, - SensorStateClass.MEASUREMENT, - EntityCategory.DIAGNOSTIC, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_power", - "5.678", - f"{CONF_FAKE_NAME} Power", - UnitOfPower.WATT, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_energy", - "1.234", - f"{CONF_FAKE_NAME} Energy", - UnitOfEnergy.KILO_WATT_HOUR, - SensorStateClass.TOTAL_INCREASING, - None, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_voltage", - "230.0", - f"{CONF_FAKE_NAME} Voltage", - UnitOfElectricPotential.VOLT, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current", - "0.025", - f"{CONF_FAKE_NAME} Current", - UnitOfElectricCurrent.AMPERE, - SensorStateClass.MEASUREMENT, - None, - ], - ) - - for sensor in sensors: - state = hass.states.get(sensor[0]) - assert state - assert state.state == sensor[1] - assert state.attributes[ATTR_FRIENDLY_NAME] == sensor[2] - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == sensor[3] - assert state.attributes[ATTR_STATE_CLASS] == sensor[4] - assert state.attributes[ATTR_STATE_CLASS] == sensor[4] - entry = entity_registry.async_get(sensor[0]) - assert entry - assert entry.entity_category is sensor[5] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device on.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -133,7 +65,7 @@ async def test_turn_off(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device off.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -149,7 +81,7 @@ async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None: device = FritzDeviceSwitchMock() device.lock = True - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -173,7 +105,7 @@ async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None: async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) assert fritz().update_devices.call_count == 1 @@ -191,9 +123,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceSwitchMock() fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 @@ -211,7 +144,7 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No device.voltage = 0 device.energy = 0 device.power = 0 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -223,7 +156,7 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/fronius/snapshots/test_sensor.ambr b/tests/components/fronius/snapshots/test_sensor.ambr index 5384e9c6389..63d2c85986a 100644 --- a/tests/components/fronius/snapshots/test_sensor.ambr +++ b/tests/components/fronius/snapshots/test_sensor.ambr @@ -3179,7 +3179,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Relative self consumption', + 'original_name': 'Relative self-consumption', 'platform': 'fronius', 'previous_unique_id': None, 'supported_features': 0, @@ -3191,7 +3191,7 @@ # name: test_gen24[sensor.solarnet_relative_self_consumption-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'SolarNet Relative self consumption', + 'friendly_name': 'SolarNet Relative self-consumption', 'state_class': , 'unit_of_measurement': '%', }), @@ -7163,7 +7163,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Relative self consumption', + 'original_name': 'Relative self-consumption', 'platform': 'fronius', 'previous_unique_id': None, 'supported_features': 0, @@ -7175,7 +7175,7 @@ # name: test_gen24_storage[sensor.solarnet_relative_self_consumption-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'SolarNet Relative self consumption', + 'friendly_name': 'SolarNet Relative self-consumption', 'state_class': , 'unit_of_measurement': '%', }), @@ -10087,7 +10087,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Relative self consumption', + 'original_name': 'Relative self-consumption', 'platform': 'fronius', 'previous_unique_id': None, 'supported_features': 0, @@ -10099,7 +10099,7 @@ # name: test_primo_s0[sensor.solarnet_relative_self_consumption-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'SolarNet Relative self consumption', + 'friendly_name': 'SolarNet Relative self-consumption', 'state_class': , 'unit_of_measurement': '%', }), diff --git a/tests/components/frontend/test_storage.py b/tests/components/frontend/test_storage.py index ce7f7aeb4a1..360ca151551 100644 --- a/tests/components/frontend/test_storage.py +++ b/tests/components/frontend/test_storage.py @@ -13,9 +13,9 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def setup_frontend(hass: HomeAssistant) -> None: +async def setup_frontend(hass: HomeAssistant) -> None: """Fixture to setup the frontend.""" - hass.loop.run_until_complete(async_setup_component(hass, "frontend", {})) + await async_setup_component(hass, "frontend", {}) async def test_get_user_data_empty( diff --git a/tests/components/fully_kiosk/test_media_player.py b/tests/components/fully_kiosk/test_media_player.py index aa53421616f..e46a50100b2 100644 --- a/tests/components/fully_kiosk/test_media_player.py +++ b/tests/components/fully_kiosk/test_media_player.py @@ -184,6 +184,7 @@ async def test_browse_media( "media_content_id": "media-source://media_source/local/test.mp3", "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, } diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 7e2e92f025b..65be83bad20 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -1119,6 +1119,52 @@ async def test_precision(hass: HomeAssistant) -> None: assert state.attributes.get("target_temp_step") == 0.1 +@pytest.fixture( + params=[ + HVACMode.HEAT, + HVACMode.COOL, + ] +) +async def setup_comp_10(hass: HomeAssistant, request: pytest.FixtureRequest) -> None: + """Initialize components.""" + assert await async_setup_component( + hass, + CLIMATE_DOMAIN, + { + "climate": { + "platform": "generic_thermostat", + "name": "test", + "cold_tolerance": 0, + "hot_tolerance": 0, + "target_temp": 25, + "heater": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "initial_hvac_mode": request.param, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("setup_comp_10") +async def test_zero_tolerances(hass: HomeAssistant) -> None: + """Test that having a zero tolerance doesn't cause the switch to flip-flop.""" + + # if the switch is off, it should remain off + calls = _setup_switch(hass, False) + _setup_sensor(hass, 25) + await hass.async_block_till_done() + await common.async_set_temperature(hass, 25) + assert len(calls) == 0 + + # if the switch is on, it should turn off + calls = _setup_switch(hass, True) + _setup_sensor(hass, 25) + await hass.async_block_till_done() + await common.async_set_temperature(hass, 25) + assert len(calls) == 1 + + async def test_custom_setup_params(hass: HomeAssistant) -> None: """Test the setup with custom parameters.""" result = await async_setup_component( diff --git a/tests/components/geo_location/test_trigger.py b/tests/components/geo_location/test_trigger.py index 7673f357a08..0a9ad8a5b16 100644 --- a/tests/components/geo_location/test_trigger.py +++ b/tests/components/geo_location/test_trigger.py @@ -29,22 +29,20 @@ def calls(hass: HomeAssistant) -> list[ServiceCall]: @pytest.fixture(autouse=True) -def setup_comp(hass: HomeAssistant) -> None: +async def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" mock_component(hass, "group") - hass.loop.run_until_complete( - async_setup_component( - hass, - zone.DOMAIN, - { - "zone": { - "name": "test", - "latitude": 32.880837, - "longitude": -117.237561, - "radius": 250, - } - }, - ) + await async_setup_component( + hass, + zone.DOMAIN, + { + "zone": { + "name": "test", + "latitude": 32.880837, + "longitude": -117.237561, + "radius": 250, + } + }, ) diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index de882a6f791..e5f4e512579 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -570,7 +570,7 @@ async def test_reauth_flow( ("primary_calendar_error", "primary_calendar_status", "reason"), [ (ClientError(), None, "cannot_connect"), - (None, HTTPStatus.FORBIDDEN, "api_disabled"), + (None, HTTPStatus.FORBIDDEN, "calendar_api_disabled"), (None, HTTPStatus.SERVICE_UNAVAILABLE, "cannot_connect"), ], ) diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 6be58f50469..015c20e8393 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -259,6 +259,13 @@ DEMO_DEVICES = [ "type": "action.devices.types.SETTOP", "willReportState": False, }, + { + "id": "media_player.search", + "name": {"name": "Search"}, + "traits": ["action.devices.traits.MediaState", "action.devices.traits.OnOff"], + "type": "action.devices.types.SETTOP", + "willReportState": False, + }, { "id": "fan.living_room_fan", "name": {"name": "Living Room Fan"}, diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 035a8d151c4..26541d33613 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -1,6 +1,5 @@ """The tests for the Google Assistant component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus import json from unittest.mock import patch @@ -38,32 +37,28 @@ def auth_header(hass_access_token: str) -> dict[str, str]: @pytest.fixture -def assistant_client( - event_loop: AbstractEventLoop, +async def assistant_client( hass: core.HomeAssistant, hass_client_no_auth: ClientSessionGenerator, ) -> TestClient: """Create web client for the Google Assistant API.""" - loop = event_loop - loop.run_until_complete( - setup.async_setup_component( - hass, - "google_assistant", - { - "google_assistant": { - "project_id": PROJECT_ID, - "entity_config": { - "light.ceiling_lights": { - "aliases": ["top lights", "ceiling lights"], - "name": "Roof Lights", - } - }, - } - }, - ) + await setup.async_setup_component( + hass, + "google_assistant", + { + "google_assistant": { + "project_id": PROJECT_ID, + "entity_config": { + "light.ceiling_lights": { + "aliases": ["top lights", "ceiling lights"], + "name": "Roof Lights", + } + }, + } + }, ) - return loop.run_until_complete(hass_client_no_auth()) + return await hass_client_no_auth() @pytest.fixture(autouse=True) @@ -87,16 +82,12 @@ async def wanted_platforms_only() -> None: @pytest.fixture -def hass_fixture( - event_loop: AbstractEventLoop, hass: core.HomeAssistant -) -> core.HomeAssistant: +async def hass_fixture(hass: core.HomeAssistant) -> core.HomeAssistant: """Set up a Home Assistant instance for these tests.""" - loop = event_loop - # We need to do this to get access to homeassistant/turn_(on,off) - loop.run_until_complete(setup.async_setup_component(hass, core.DOMAIN, {})) + await setup.async_setup_component(hass, core.DOMAIN, {}) - loop.run_until_complete(setup.async_setup_component(hass, "demo", {})) + await setup.async_setup_component(hass, "demo", {}) return hass diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 3b43728988b..2dba083185d 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -235,11 +235,11 @@ async def test_sync_message(hass: HomeAssistant, registries) -> None: ], }, { - "setting_name": "none", + "setting_name": "off", "setting_values": [ { "lang": "en", - "setting_synonym": ["none"], + "setting_synonym": ["off"], } ], }, @@ -356,9 +356,9 @@ async def test_sync_in_area(area_on_device, hass: HomeAssistant, registries) -> ], }, { - "setting_name": "none", + "setting_name": "off", "setting_values": [ - {"lang": "en", "setting_synonym": ["none"]} + {"lang": "en", "setting_synonym": ["off"]} ], }, ], @@ -957,9 +957,9 @@ async def test_unavailable_state_does_sync(hass: HomeAssistant) -> None: ], }, { - "setting_name": "none", + "setting_name": "off", "setting_values": [ - {"lang": "en", "setting_synonym": ["none"]} + {"lang": "en", "setting_synonym": ["off"]} ], }, ], diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index ec98bdd6529..ce257e61d53 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -6,7 +6,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'param1': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description='Test parameters', enum=None, format=None, items=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), 'param2': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=None), 'param3': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'json': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=)}, property_ordering=None, required=[], type=)}, property_ordering=None, required=[], type=))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="You are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.\nCurrent time is 05:00:00. Today's date is 2024-05-24.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=1500, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'param1': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description='Test parameters', enum=None, format=None, items=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), 'param2': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=None), 'param3': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'json': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=)}, property_ordering=None, required=[], type=)}, property_ordering=None, required=[], type=))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', @@ -39,7 +39,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=None)], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="You are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.\nCurrent time is 05:00:00. Today's date is 2024-05-24.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=1500, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=None)], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', @@ -72,7 +72,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'param1': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description='Test parameters', enum=None, format=None, items=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), 'param2': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=None), 'param3': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'json': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=)}, property_ordering=None, required=[], type=)}, property_ordering=None, required=[], type=))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None), Tool(function_declarations=None, retrieval=None, google_search=GoogleSearch(), google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="You are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.\nCurrent time is 05:00:00. Today's date is 2024-05-24.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=1500, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'param1': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description='Test parameters', enum=None, format=None, items=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), 'param2': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=None), 'param3': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'json': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=)}, property_ordering=None, required=[], type=)}, property_ordering=None, required=[], type=))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None), Tool(function_declarations=None, retrieval=None, google_search=GoogleSearch(), google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index b445499ad49..60d388d0502 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -9,7 +9,7 @@ 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'max_tokens': 150, + 'max_tokens': 1500, 'prompt': 'Speak like a pirate', 'recommended': False, 'sexual_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index ce882adf6e6..d8e54b15f61 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -1,4 +1,21 @@ # serializer version: 1 +# name: test_generate_content_file_processing_succeeds + list([ + tuple( + '', + tuple( + ), + dict({ + 'contents': list([ + 'Describe this image from my doorbell camera', + File(name='doorbell_snapshot.jpg', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), + File(name='context.txt', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), + ]), + 'model': 'models/gemini-2.0-flash', + }), + ), + ]) +# --- # name: test_generate_content_service_with_image list([ tuple( diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index 8fda02b335d..13063580c95 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -125,7 +125,6 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: ( { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "none", CONF_PROMPT: "bla", }, { @@ -162,12 +161,12 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: }, { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, None, @@ -235,7 +234,7 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], CONF_TEMPERATURE: 0.3, CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_TOP_P: RECOMMENDED_TOP_P, @@ -263,6 +262,24 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: }, {CONF_USE_GOOGLE_SEARCH_TOOL: "invalid_google_search_option"}, ), + ( + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: "assist", + }, + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: ["assist"], + }, + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: ["assist"], + }, + None, + ), ], ) @pytest.mark.usefixtures("mock_init_component") diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index a08acc0df3f..94308260f74 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, Mock, mock_open, patch +from google.genai.types import File, FileState import pytest from requests.exceptions import Timeout from syrupy.assertion import SnapshotAssertion @@ -91,6 +92,117 @@ async def test_generate_content_service_with_image( assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_content_file_processing_succeeds( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test generate content service.""" + stubbed_generated_content = ( + "A mail carrier is at your front door delivering a package" + ) + + with ( + patch( + "google.genai.models.AsyncModels.generate_content", + return_value=Mock( + text=stubbed_generated_content, + prompt_feedback=None, + candidates=[Mock()], + ), + ) as mock_generate, + patch("pathlib.Path.exists", return_value=True), + patch.object(hass.config, "is_allowed_path", return_value=True), + patch("builtins.open", mock_open(read_data="this is an image")), + patch("mimetypes.guess_type", return_value=["image/jpeg"]), + patch( + "google.genai.files.Files.upload", + side_effect=[ + File(name="doorbell_snapshot.jpg", state=FileState.ACTIVE), + File(name="context.txt", state=FileState.PROCESSING), + ], + ), + patch( + "google.genai.files.AsyncFiles.get", + side_effect=[ + File(name="context.txt", state=FileState.PROCESSING), + File(name="context.txt", state=FileState.ACTIVE), + ], + ), + ): + response = await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + { + "prompt": "Describe this image from my doorbell camera", + "filenames": ["doorbell_snapshot.jpg", "context.txt", "context.txt"], + }, + blocking=True, + return_response=True, + ) + + assert response == { + "text": stubbed_generated_content, + } + assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_content_file_processing_fails( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test generate content service.""" + stubbed_generated_content = ( + "A mail carrier is at your front door delivering a package" + ) + + with ( + patch( + "google.genai.models.AsyncModels.generate_content", + return_value=Mock( + text=stubbed_generated_content, + prompt_feedback=None, + candidates=[Mock()], + ), + ), + patch("pathlib.Path.exists", return_value=True), + patch.object(hass.config, "is_allowed_path", return_value=True), + patch("builtins.open", mock_open(read_data="this is an image")), + patch("mimetypes.guess_type", return_value=["image/jpeg"]), + patch( + "google.genai.files.Files.upload", + side_effect=[ + File(name="doorbell_snapshot.jpg", state=FileState.ACTIVE), + File(name="context.txt", state=FileState.PROCESSING), + ], + ), + patch( + "google.genai.files.AsyncFiles.get", + side_effect=[ + File(name="context.txt", state=FileState.PROCESSING), + File( + name="context.txt", + state=FileState.FAILED, + error={"message": "File processing failed"}, + ), + ], + ), + pytest.raises( + HomeAssistantError, + match="File `context.txt` processing failed, reason: File processing failed", + ), + ): + await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + { + "prompt": "Describe this image from my doorbell camera", + "filenames": ["doorbell_snapshot.jpg", "context.txt", "context.txt"], + }, + blocking=True, + return_response=True, + ) + + @pytest.mark.usefixtures("mock_init_component") async def test_generate_content_service_error( hass: HomeAssistant, diff --git a/tests/components/google_travel_time/conftest.py b/tests/components/google_travel_time/conftest.py index 7d1e4791eee..ef066bfe2a4 100644 --- a/tests/components/google_travel_time/conftest.py +++ b/tests/components/google_travel_time/conftest.py @@ -2,9 +2,11 @@ from collections.abc import Generator from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, patch -from googlemaps.exceptions import ApiError, Timeout, TransportError +from google.maps.routing_v2 import ComputeRoutesResponse, Route +from google.protobuf import duration_pb2 +from google.type import localized_text_pb2 import pytest from homeassistant.components.google_travel_time.const import DOMAIN @@ -30,8 +32,8 @@ async def mock_config_fixture( return config_entry -@pytest.fixture(name="bypass_setup") -def bypass_setup_fixture() -> Generator[None]: +@pytest.fixture +def mock_setup_entry() -> Generator[None]: """Bypass entry setup.""" with patch( "homeassistant.components.google_travel_time.async_setup_entry", @@ -40,48 +42,42 @@ def bypass_setup_fixture() -> Generator[None]: yield -@pytest.fixture(name="bypass_platform_setup") -def bypass_platform_setup_fixture() -> Generator[None]: - """Bypass platform setup.""" - with patch( - "homeassistant.components.google_travel_time.sensor.async_setup_entry", - return_value=True, - ): - yield - - -@pytest.fixture(name="validate_config_entry") -def validate_config_entry_fixture() -> Generator[MagicMock]: - """Return valid config entry.""" +@pytest.fixture +def routes_mock() -> Generator[AsyncMock]: + """Return valid API result.""" with ( - patch("homeassistant.components.google_travel_time.helpers.Client"), patch( - "homeassistant.components.google_travel_time.helpers.distance_matrix" - ) as distance_matrix_mock, + "homeassistant.components.google_travel_time.helpers.RoutesAsyncClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.google_travel_time.sensor.RoutesAsyncClient", + new=mock_client, + ), ): - distance_matrix_mock.return_value = None - yield distance_matrix_mock - - -@pytest.fixture(name="invalidate_config_entry") -def invalidate_config_entry_fixture(validate_config_entry: MagicMock) -> None: - """Return invalid config entry.""" - validate_config_entry.side_effect = ApiError("test") - - -@pytest.fixture(name="invalid_api_key") -def invalid_api_key_fixture(validate_config_entry: MagicMock) -> None: - """Throw a REQUEST_DENIED ApiError.""" - validate_config_entry.side_effect = ApiError("REQUEST_DENIED", "Invalid API key.") - - -@pytest.fixture(name="timeout") -def timeout_fixture(validate_config_entry: MagicMock) -> None: - """Throw a Timeout exception.""" - validate_config_entry.side_effect = Timeout() - - -@pytest.fixture(name="transport_error") -def transport_error_fixture(validate_config_entry: MagicMock) -> None: - """Throw a TransportError exception.""" - validate_config_entry.side_effect = TransportError("Unknown.") + client_mock = mock_client.return_value + client_mock.compute_routes.return_value = ComputeRoutesResponse( + mapping={ + "routes": [ + Route( + mapping={ + "localized_values": Route.RouteLocalizedValues( + mapping={ + "distance": localized_text_pb2.LocalizedText( + text="21.3 km" + ), + "duration": localized_text_pb2.LocalizedText( + text="27 mins" + ), + "static_duration": localized_text_pb2.LocalizedText( + text="26 mins" + ), + } + ), + "duration": duration_pb2.Duration(seconds=1620), + } + ) + ] + } + ) + yield client_mock diff --git a/tests/components/google_travel_time/const.py b/tests/components/google_travel_time/const.py index 29cf32b8e29..dd83e1366ac 100644 --- a/tests/components/google_travel_time/const.py +++ b/tests/components/google_travel_time/const.py @@ -3,13 +3,15 @@ from homeassistant.components.google_travel_time.const import ( CONF_DESTINATION, CONF_ORIGIN, + CONF_UNITS, + UNITS_METRIC, ) -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_MODE MOCK_CONFIG = { CONF_API_KEY: "api_key", CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", + CONF_DESTINATION: "49.983862755708444,8.223882827079068", } RECONFIGURE_CONFIG = { @@ -17,3 +19,5 @@ RECONFIGURE_CONFIG = { CONF_ORIGIN: "location3", CONF_DESTINATION: "location4", } + +DEFAULT_OPTIONS = {CONF_MODE: "driving", CONF_UNITS: UNITS_METRIC} diff --git a/tests/components/google_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index 5f9d5d4549b..8cdb3c270d0 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -1,10 +1,10 @@ """Test the Google Maps Travel Time config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from google.api_core.exceptions import GatewayTimeout, GoogleAPIError, Unauthorized import pytest -from homeassistant import config_entries from homeassistant.components.google_travel_time.const import ( ARRIVAL_TIME, CONF_ARRIVAL_TIME, @@ -23,26 +23,32 @@ from homeassistant.components.google_travel_time.const import ( DOMAIN, UNITS_IMPERIAL, ) +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import MOCK_CONFIG, RECONFIGURE_CONFIG +from .const import DEFAULT_OPTIONS, MOCK_CONFIG, RECONFIGURE_CONFIG from tests.common import MockConfigEntry async def assert_common_reconfigure_steps( - hass: HomeAssistant, reconfigure_result: config_entries.ConfigFlowResult + hass: HomeAssistant, reconfigure_result: ConfigFlowResult ) -> None: """Step through and assert the happy case reconfigure flow.""" + client_mock = AsyncMock() with ( - patch("homeassistant.components.google_travel_time.helpers.Client"), patch( - "homeassistant.components.google_travel_time.helpers.distance_matrix", - return_value=None, + "homeassistant.components.google_travel_time.helpers.RoutesAsyncClient", + return_value=client_mock, + ), + patch( + "homeassistant.components.google_travel_time.sensor.RoutesAsyncClient", + return_value=client_mock, ), ): + client_mock.compute_routes.return_value = None reconfigure_successful_result = await hass.config_entries.flow.async_configure( reconfigure_result["flow_id"], RECONFIGURE_CONFIG, @@ -56,38 +62,28 @@ async def assert_common_reconfigure_steps( async def assert_common_create_steps( - hass: HomeAssistant, user_step_result: config_entries.ConfigFlowResult + hass: HomeAssistant, result: ConfigFlowResult ) -> None: """Step through and assert the happy case create flow.""" - with ( - patch("homeassistant.components.google_travel_time.helpers.Client"), - patch( - "homeassistant.components.google_travel_time.helpers.distance_matrix", - return_value=None, - ), - ): - create_result = await hass.config_entries.flow.async_configure( - user_step_result["flow_id"], - MOCK_CONFIG, - ) - assert create_result["type"] is FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() - - entry = hass.config_entries.async_entries(DOMAIN)[0] - assert entry.title == DEFAULT_NAME - assert entry.data == { - CONF_NAME: DEFAULT_NAME, - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == { + CONF_NAME: DEFAULT_NAME, + CONF_API_KEY: "api_key", + CONF_ORIGIN: "location1", + CONF_DESTINATION: "49.983862755708444,8.223882827079068", + } -@pytest.mark.usefixtures("validate_config_entry", "bypass_setup") +@pytest.mark.usefixtures("routes_mock", "mock_setup_entry") async def test_minimum_fields(hass: HomeAssistant) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None @@ -95,255 +91,101 @@ async def test_minimum_fields(hass: HomeAssistant) -> None: await assert_common_create_steps(hass, result) -@pytest.mark.usefixtures("invalidate_config_entry") -async def test_invalid_config_entry(hass: HomeAssistant) -> None: - """Test we get the form.""" +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception", "error"), + [ + (GoogleAPIError("test"), "cannot_connect"), + (GatewayTimeout("Timeout error."), "timeout_connect"), + (Unauthorized("Invalid API key."), "invalid_auth"), + ], +) +async def test_errors( + hass: HomeAssistant, routes_mock: AsyncMock, exception: Exception, error: str +) -> None: + """Test errors in the flow.""" + routes_mock.compute_routes.side_effect = exception result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( + + result = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - await assert_common_create_steps(hass, result2) - - -@pytest.mark.usefixtures("invalid_api_key") -async def test_invalid_api_key(hass: HomeAssistant) -> 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"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_CONFIG, - ) + assert result["errors"] == {"base": error} - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - await assert_common_create_steps(hass, result2) - - -@pytest.mark.usefixtures("transport_error") -async def test_transport_error(hass: HomeAssistant) -> 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"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_CONFIG, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - await assert_common_create_steps(hass, result2) - - -@pytest.mark.usefixtures("timeout") -async def test_timeout(hass: HomeAssistant) -> 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"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_CONFIG, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "timeout_connect"} - await assert_common_create_steps(hass, result2) - - -async def test_malformed_api_key(hass: HomeAssistant) -> 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"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_CONFIG, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + routes_mock.compute_routes.side_effect = None + await assert_common_create_steps(hass, result) @pytest.mark.parametrize( ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], + [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) -@pytest.mark.usefixtures("validate_config_entry", "bypass_setup") +@pytest.mark.usefixtures("routes_mock", "mock_setup_entry") async def test_reconfigure(hass: HomeAssistant, mock_config: MockConfigEntry) -> None: """Test reconfigure flow.""" - reconfigure_result = await mock_config.start_reconfigure_flow(hass) - assert reconfigure_result["type"] is FlowResultType.FORM - assert reconfigure_result["step_id"] == "reconfigure" + result = await mock_config.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" - await assert_common_reconfigure_steps(hass, reconfigure_result) + await assert_common_reconfigure_steps(hass, result) +@pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize( ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +@pytest.mark.parametrize( + ("exception", "error"), [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) + (GoogleAPIError("test"), "cannot_connect"), + (GatewayTimeout("Timeout error."), "timeout_connect"), + (Unauthorized("Invalid API key."), "invalid_auth"), ], ) -@pytest.mark.usefixtures("invalidate_config_entry") async def test_reconfigure_invalid_config_entry( - hass: HomeAssistant, mock_config: MockConfigEntry + hass: HomeAssistant, + mock_config: MockConfigEntry, + routes_mock: AsyncMock, + exception: Exception, + error: str, ) -> None: """Test we get the form.""" result = await mock_config.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( + + routes_mock.compute_routes.side_effect = exception + + result = await hass.config_entries.flow.async_configure( result["flow_id"], RECONFIGURE_CONFIG, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - await assert_common_reconfigure_steps(hass, result2) - - -@pytest.mark.parametrize( - ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], -) -@pytest.mark.usefixtures("invalid_api_key") -async def test_reconfigure_invalid_api_key( - hass: HomeAssistant, mock_config: MockConfigEntry -) -> None: - """Test we get the form.""" - result = await mock_config.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - RECONFIGURE_CONFIG, - ) + assert result["errors"] == {"base": error} - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - await assert_common_reconfigure_steps(hass, result2) + routes_mock.compute_routes.side_effect = None + + await assert_common_reconfigure_steps(hass, result) @pytest.mark.parametrize( ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], + [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) -@pytest.mark.usefixtures("transport_error") -async def test_reconfigure_transport_error( - hass: HomeAssistant, mock_config: MockConfigEntry -) -> None: - """Test we get the form.""" - result = await mock_config.start_reconfigure_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - RECONFIGURE_CONFIG, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - await assert_common_reconfigure_steps(hass, result2) - - -@pytest.mark.parametrize( - ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], -) -@pytest.mark.usefixtures("timeout") -async def test_reconfigure_timeout( - hass: HomeAssistant, mock_config: MockConfigEntry -) -> None: - """Test we get the form.""" - result = await mock_config.start_reconfigure_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - RECONFIGURE_CONFIG, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "timeout_connect"} - await assert_common_reconfigure_steps(hass, result2) - - -@pytest.mark.parametrize( - ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], -) -@pytest.mark.usefixtures("validate_config_entry") +@pytest.mark.usefixtures("routes_mock") async def test_options_flow(hass: HomeAssistant, mock_config: MockConfigEntry) -> None: """Test options flow.""" - result = await hass.config_entries.options.async_init( - mock_config.entry_id, data=None - ) + result = await hass.config_entries.options.async_init(mock_config.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -356,7 +198,7 @@ async def test_options_flow(hass: HomeAssistant, mock_config: MockConfigEntry) - CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, CONF_TIME_TYPE: ARRIVAL_TIME, - CONF_TIME: "test", + CONF_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -369,7 +211,7 @@ async def test_options_flow(hass: HomeAssistant, mock_config: MockConfigEntry) - CONF_LANGUAGE: "en", CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, - CONF_ARRIVAL_TIME: "test", + CONF_ARRIVAL_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -380,7 +222,7 @@ async def test_options_flow(hass: HomeAssistant, mock_config: MockConfigEntry) - CONF_LANGUAGE: "en", CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, - CONF_ARRIVAL_TIME: "test", + CONF_ARRIVAL_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -389,24 +231,14 @@ async def test_options_flow(hass: HomeAssistant, mock_config: MockConfigEntry) - @pytest.mark.parametrize( ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], + [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) -@pytest.mark.usefixtures("validate_config_entry") +@pytest.mark.usefixtures("routes_mock") async def test_options_flow_departure_time( hass: HomeAssistant, mock_config: MockConfigEntry ) -> None: """Test options flow with departure time.""" - result = await hass.config_entries.options.async_init( - mock_config.entry_id, data=None - ) + result = await hass.config_entries.options.async_init(mock_config.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -419,7 +251,7 @@ async def test_options_flow_departure_time( CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, CONF_TIME_TYPE: DEPARTURE_TIME, - CONF_TIME: "test", + CONF_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -432,7 +264,7 @@ async def test_options_flow_departure_time( CONF_LANGUAGE: "en", CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, - CONF_DEPARTURE_TIME: "test", + CONF_DEPARTURE_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -443,7 +275,7 @@ async def test_options_flow_departure_time( CONF_LANGUAGE: "en", CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, - CONF_DEPARTURE_TIME: "test", + CONF_DEPARTURE_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -458,7 +290,7 @@ async def test_options_flow_departure_time( { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, - CONF_DEPARTURE_TIME: "test", + CONF_DEPARTURE_TIME: "08:00", }, ), ( @@ -466,19 +298,17 @@ async def test_options_flow_departure_time( { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, - CONF_ARRIVAL_TIME: "test", + CONF_ARRIVAL_TIME: "08:00", }, ), ], ) -@pytest.mark.usefixtures("validate_config_entry") +@pytest.mark.usefixtures("routes_mock") async def test_reset_departure_time( hass: HomeAssistant, mock_config: MockConfigEntry ) -> None: """Test resetting departure time.""" - result = await hass.config_entries.options.async_init( - mock_config.entry_id, data=None - ) + result = await hass.config_entries.options.async_init(mock_config.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -492,6 +322,8 @@ async def test_reset_departure_time( }, ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_config.options == { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, @@ -506,7 +338,7 @@ async def test_reset_departure_time( { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, - CONF_ARRIVAL_TIME: "test", + CONF_ARRIVAL_TIME: "08:00", }, ), ( @@ -514,19 +346,17 @@ async def test_reset_departure_time( { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, - CONF_DEPARTURE_TIME: "test", + CONF_DEPARTURE_TIME: "08:00", }, ), ], ) -@pytest.mark.usefixtures("validate_config_entry") +@pytest.mark.usefixtures("routes_mock") async def test_reset_arrival_time( hass: HomeAssistant, mock_config: MockConfigEntry ) -> None: """Test resetting arrival time.""" - result = await hass.config_entries.options.async_init( - mock_config.entry_id, data=None - ) + result = await hass.config_entries.options.async_init(mock_config.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -540,6 +370,8 @@ async def test_reset_arrival_time( }, ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_config.options == { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, @@ -557,7 +389,7 @@ async def test_reset_arrival_time( CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, CONF_TIME_TYPE: ARRIVAL_TIME, - CONF_TIME: "test", + CONF_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -565,14 +397,12 @@ async def test_reset_arrival_time( ) ], ) -@pytest.mark.usefixtures("validate_config_entry") +@pytest.mark.usefixtures("routes_mock") async def test_reset_options_flow_fields( hass: HomeAssistant, mock_config: MockConfigEntry ) -> None: """Test resetting options flow fields that are not time related to None.""" - result = await hass.config_entries.options.async_init( - mock_config.entry_id, data=None - ) + result = await hass.config_entries.options.async_init(mock_config.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -583,52 +413,39 @@ async def test_reset_options_flow_fields( CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, CONF_TIME_TYPE: ARRIVAL_TIME, - CONF_TIME: "test", + CONF_TIME: "08:00", }, ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_config.options == { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, - CONF_ARRIVAL_TIME: "test", + CONF_ARRIVAL_TIME: "08:00", } -@pytest.mark.usefixtures("validate_config_entry", "bypass_setup") -async def test_dupe(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +@pytest.mark.usefixtures("routes_mock", "mock_setup_entry") +async def test_dupe(hass: HomeAssistant, mock_config: MockConfigEntry) -> None: """Test setting up the same entry data twice is OK.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_API_KEY: "test", CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", + CONF_DESTINATION: "49.983862755708444,8.223882827079068", }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_API_KEY: "test", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/google_travel_time/test_init.py b/tests/components/google_travel_time/test_init.py new file mode 100644 index 00000000000..246804d6bbc --- /dev/null +++ b/tests/components/google_travel_time/test_init.py @@ -0,0 +1,82 @@ +"""Tests for Google Maps Travel Time init.""" + +import pytest + +from homeassistant.components.google_travel_time.const import ( + ARRIVAL_TIME, + CONF_TIME, + CONF_TIME_TYPE, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .const import DEFAULT_OPTIONS, MOCK_CONFIG + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("v1", "v2"), + [ + ("08:00", "08:00"), + ("08:00:00", "08:00:00"), + ("1742144400", "17:00"), + ("now", None), + (None, None), + ], +) +@pytest.mark.usefixtures("routes_mock", "mock_setup_entry") +async def test_migrate_entry_v1_v2( + hass: HomeAssistant, + v1: str, + v2: str | None, +) -> None: + """Test successful migration of entry data.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=1, + data=MOCK_CONFIG, + options={ + **DEFAULT_OPTIONS, + CONF_TIME_TYPE: ARRIVAL_TIME, + CONF_TIME: v1, + }, + ) + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + + assert updated_entry.state is ConfigEntryState.LOADED + assert updated_entry.version == 2 + assert updated_entry.options[CONF_TIME] == v2 + + +@pytest.mark.usefixtures("routes_mock", "mock_setup_entry") +async def test_migrate_entry_v1_v2_invalid_time( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test successful migration of entry data.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=1, + data=MOCK_CONFIG, + options={ + **DEFAULT_OPTIONS, + CONF_TIME_TYPE: ARRIVAL_TIME, + CONF_TIME: "invalid", + }, + ) + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + + assert updated_entry.state is ConfigEntryState.LOADED + assert updated_entry.version == 2 + assert updated_entry.options[CONF_TIME] is None + assert "Invalid time format found while migrating" in caplog.text diff --git a/tests/components/google_travel_time/test_sensor.py b/tests/components/google_travel_time/test_sensor.py index 9ee6ebbbc7b..58843d8275c 100644 --- a/tests/components/google_travel_time/test_sensor.py +++ b/tests/components/google_travel_time/test_sensor.py @@ -1,97 +1,48 @@ """Test the Google Maps Travel Time sensors.""" -from collections.abc import Generator -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock -from googlemaps.exceptions import ApiError, Timeout, TransportError +from freezegun.api import FrozenDateTimeFactory +from google.api_core.exceptions import GoogleAPIError +from google.maps.routing_v2 import Units import pytest from homeassistant.components.google_travel_time.config_flow import default_options from homeassistant.components.google_travel_time.const import ( CONF_ARRIVAL_TIME, CONF_DEPARTURE_TIME, + CONF_TRANSIT_MODE, + CONF_TRANSIT_ROUTING_PREFERENCE, + CONF_UNITS, DOMAIN, - UNITS_IMPERIAL, UNITS_METRIC, ) from homeassistant.components.google_travel_time.sensor import SCAN_INTERVAL +from homeassistant.const import CONF_MODE, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import ( METRIC_SYSTEM, US_CUSTOMARY_SYSTEM, UnitSystem, ) -from .const import MOCK_CONFIG +from .const import DEFAULT_OPTIONS, MOCK_CONFIG from tests.common import MockConfigEntry, async_fire_time_changed -@pytest.fixture(name="mock_update") -def mock_update_fixture() -> Generator[MagicMock]: - """Mock an update to the sensor.""" - with ( - patch("homeassistant.components.google_travel_time.sensor.Client"), - patch( - "homeassistant.components.google_travel_time.sensor.distance_matrix" - ) as distance_matrix_mock, - ): - distance_matrix_mock.return_value = { - "rows": [ - { - "elements": [ - { - "duration_in_traffic": { - "value": 1620, - "text": "27 mins", - }, - "duration": { - "value": 1560, - "text": "26 mins", - }, - "distance": {"text": "21.3 km"}, - } - ] - } - ] - } - yield distance_matrix_mock - - -@pytest.fixture(name="mock_update_duration") -def mock_update_duration_fixture(mock_update: MagicMock) -> MagicMock: - """Mock an update to the sensor returning no duration_in_traffic.""" - mock_update.return_value = { - "rows": [ - { - "elements": [ - { - "duration": { - "value": 1560, - "text": "26 mins", - }, - "distance": {"text": "21.3 km"}, - } - ] - } - ] - } - return mock_update - - @pytest.fixture(name="mock_update_empty") -def mock_update_empty_fixture(mock_update: MagicMock) -> MagicMock: +def mock_update_empty_fixture(routes_mock: AsyncMock) -> AsyncMock: """Mock an update to the sensor with an empty response.""" - mock_update.return_value = None - return mock_update + routes_mock.compute_routes.return_value = None + return routes_mock @pytest.mark.parametrize( ("data", "options"), - [(MOCK_CONFIG, {})], + [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) -@pytest.mark.usefixtures("mock_update", "mock_config") +@pytest.mark.usefixtures("routes_mock", "mock_config") async def test_sensor(hass: HomeAssistant) -> None: """Test that sensor works.""" assert hass.states.get("sensor.google_travel_time").state == "27" @@ -114,7 +65,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ) assert ( hass.states.get("sensor.google_travel_time").attributes["destination"] - == "location2" + == "49.983862755708444,8.223882827079068" ) assert ( hass.states.get("sensor.google_travel_time").attributes["unit_of_measurement"] @@ -122,24 +73,14 @@ async def test_sensor(hass: HomeAssistant) -> None: ) -@pytest.mark.parametrize( - ("data", "options"), - [(MOCK_CONFIG, {})], -) -@pytest.mark.usefixtures("mock_update_duration", "mock_config") -async def test_sensor_duration(hass: HomeAssistant) -> None: - """Test that sensor works with no duration_in_traffic in response.""" - assert hass.states.get("sensor.google_travel_time").state == "26" - - -@pytest.mark.parametrize( - ("data", "options"), - [(MOCK_CONFIG, {})], -) @pytest.mark.usefixtures("mock_update_empty", "mock_config") +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) async def test_sensor_empty_response(hass: HomeAssistant) -> None: """Test that sensor works for an empty response.""" - assert hass.states.get("sensor.google_travel_time").state == "unknown" + assert hass.states.get("sensor.google_travel_time").state == STATE_UNKNOWN @pytest.mark.parametrize( @@ -148,12 +89,13 @@ async def test_sensor_empty_response(hass: HomeAssistant) -> None: ( MOCK_CONFIG, { + **DEFAULT_OPTIONS, CONF_DEPARTURE_TIME: "10:00", }, ), ], ) -@pytest.mark.usefixtures("mock_update", "mock_config") +@pytest.mark.usefixtures("routes_mock", "mock_config") async def test_sensor_departure_time(hass: HomeAssistant) -> None: """Test that sensor works for departure time.""" assert hass.states.get("sensor.google_travel_time").state == "27" @@ -165,60 +107,31 @@ async def test_sensor_departure_time(hass: HomeAssistant) -> None: ( MOCK_CONFIG, { - CONF_DEPARTURE_TIME: "custom_timestamp", - }, - ), - ], -) -@pytest.mark.usefixtures("mock_update", "mock_config") -async def test_sensor_departure_time_custom_timestamp(hass: HomeAssistant) -> None: - """Test that sensor works for departure time with a custom timestamp.""" - assert hass.states.get("sensor.google_travel_time").state == "27" - - -@pytest.mark.parametrize( - ("data", "options"), - [ - ( - MOCK_CONFIG, - { + CONF_MODE: "transit", + CONF_UNITS: UNITS_METRIC, + CONF_TRANSIT_ROUTING_PREFERENCE: "fewer_transfers", + CONF_TRANSIT_MODE: "bus", CONF_ARRIVAL_TIME: "10:00", }, ), ], ) -@pytest.mark.usefixtures("mock_update", "mock_config") +@pytest.mark.usefixtures("routes_mock", "mock_config") async def test_sensor_arrival_time(hass: HomeAssistant) -> None: """Test that sensor works for arrival time.""" assert hass.states.get("sensor.google_travel_time").state == "27" -@pytest.mark.parametrize( - ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_ARRIVAL_TIME: "custom_timestamp", - }, - ), - ], -) -@pytest.mark.usefixtures("mock_update", "mock_config") -async def test_sensor_arrival_time_custom_timestamp(hass: HomeAssistant) -> None: - """Test that sensor works for arrival time with a custom timestamp.""" - assert hass.states.get("sensor.google_travel_time").state == "27" - - @pytest.mark.parametrize( ("unit_system", "expected_unit_option"), [ - (METRIC_SYSTEM, UNITS_METRIC), - (US_CUSTOMARY_SYSTEM, UNITS_IMPERIAL), + (METRIC_SYSTEM, Units.METRIC), + (US_CUSTOMARY_SYSTEM, Units.IMPERIAL), ], ) async def test_sensor_unit_system( hass: HomeAssistant, + routes_mock: AsyncMock, unit_system: UnitSystem, expected_unit_option: str, ) -> None: @@ -232,36 +145,28 @@ async def test_sensor_unit_system( entry_id="test", ) config_entry.add_to_hass(hass) - with ( - patch("homeassistant.components.google_travel_time.sensor.Client"), - patch( - "homeassistant.components.google_travel_time.sensor.distance_matrix" - ) as distance_matrix_mock, - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - distance_matrix_mock.assert_called_once() - assert distance_matrix_mock.call_args.kwargs["units"] == expected_unit_option + routes_mock.compute_routes.assert_called_once() + assert routes_mock.compute_routes.call_args.args[0].units == expected_unit_option -@pytest.mark.parametrize( - ("exception"), - [(ApiError), (TransportError), (Timeout)], -) @pytest.mark.parametrize( ("data", "options"), - [(MOCK_CONFIG, {})], + [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) async def test_sensor_exception( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mock_update: MagicMock, - mock_config: MagicMock, - exception: Exception, + routes_mock: AsyncMock, + mock_config: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test that exception gets caught.""" - mock_update.side_effect = exception("Errormessage") - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + routes_mock.compute_routes.side_effect = GoogleAPIError("Errormessage") + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() + assert hass.states.get("sensor.google_travel_time").state == STATE_UNKNOWN assert "Error getting travel time" in caplog.text diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py index c5dde6a9b9e..40748c0598e 100644 --- a/tests/components/govee_light_local/test_light.py +++ b/tests/components/govee_light_local/test_light.py @@ -1,13 +1,24 @@ """Test Govee light local.""" from errno import EADDRINUSE, ENETDOWN -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch from govee_local_api import GoveeDevice +import pytest from homeassistant.components.govee_light_local.const import DOMAIN -from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, + ATTR_RGB_COLOR, + ATTR_SUPPORTED_COLOR_MODES, + DOMAIN as LIGHT_DOMAIN, + ColorMode, +) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant from .conftest import DEFAULT_CAPABILITIES, SCENE_CAPABILITIES @@ -197,8 +208,8 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N assert light.state == "off" await hass.services.async_call( - "light", - "turn_on", + LIGHT_DOMAIN, + SERVICE_TURN_ON, {"entity_id": light.entity_id}, blocking=True, ) @@ -211,8 +222,8 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N # Turn off await hass.services.async_call( - "light", - "turn_off", + LIGHT_DOMAIN, + SERVICE_TURN_OFF, {"entity_id": light.entity_id}, blocking=True, ) @@ -224,6 +235,77 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False) +@pytest.mark.parametrize( + ("attribute", "value", "mock_call", "mock_call_args", "mock_call_kwargs"), + [ + ( + ATTR_RGB_COLOR, + [100, 255, 50], + "set_color", + [], + {"temperature": None, "rgb": (100, 255, 50)}, + ), + ( + ATTR_COLOR_TEMP_KELVIN, + 4400, + "set_color", + [], + {"temperature": 4400, "rgb": None}, + ), + (ATTR_EFFECT, "sunrise", "set_scene", ["sunrise"], {}), + ], +) +async def test_turn_on_call_order( + hass: HomeAssistant, + mock_govee_api: MagicMock, + attribute: str, + value: str | int | list[int], + mock_call: str, + mock_call_args: list[str], + mock_call_kwargs: dict[str, any], +) -> None: + """Test that turn_on is called after set_brightness/set_color/set_preset.""" + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, + ) + ] + + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS_PCT: 50, attribute: value}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_govee_api.assert_has_calls( + [ + call.set_brightness(mock_govee_api.devices[0], 50), + getattr(call, mock_call)( + mock_govee_api.devices[0], *mock_call_args, **mock_call_kwargs + ), + call.turn_on_off(mock_govee_api.devices[0], True), + ] + ) + + async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: """Test changing brightness.""" mock_govee_api.devices = [ @@ -249,8 +331,8 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) assert light.state == "off" await hass.services.async_call( - "light", - "turn_on", + LIGHT_DOMAIN, + SERVICE_TURN_ON, {"entity_id": light.entity_id, "brightness_pct": 50}, blocking=True, ) @@ -260,12 +342,12 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) assert light is not None assert light.state == "on" mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50) - assert light.attributes["brightness"] == 127 + assert light.attributes[ATTR_BRIGHTNESS] == 127 await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255}, blocking=True, ) await hass.async_block_till_done() @@ -273,13 +355,13 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_BRIGHTNESS] == 255 mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255}, blocking=True, ) await hass.async_block_till_done() @@ -287,7 +369,7 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_BRIGHTNESS] == 255 mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) @@ -316,9 +398,9 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No assert light.state == "off" await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "rgb_color": [100, 255, 50]}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_RGB_COLOR: [100, 255, 50]}, blocking=True, ) await hass.async_block_till_done() @@ -326,7 +408,7 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["rgb_color"] == (100, 255, 50) + assert light.attributes[ATTR_RGB_COLOR] == (100, 255, 50) assert light.attributes["color_mode"] == ColorMode.RGB mock_govee_api.set_color.assert_awaited_with( @@ -334,8 +416,8 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No ) await hass.services.async_call( - "light", - "turn_on", + LIGHT_DOMAIN, + SERVICE_TURN_ON, {"entity_id": light.entity_id, "kelvin": 4400}, blocking=True, ) @@ -378,9 +460,9 @@ async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: assert light.state == "off" await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "sunrise"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"}, blocking=True, ) await hass.async_block_till_done() @@ -388,7 +470,7 @@ async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] == "sunrise" + assert light.attributes[ATTR_EFFECT] == "sunrise" mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) @@ -422,16 +504,16 @@ async def test_scene_restore_rgb( # Set initial color await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "rgb_color": initial_color}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_RGB_COLOR: initial_color}, blocking=True, ) await hass.async_block_till_done() await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255}, blocking=True, ) await hass.async_block_till_done() @@ -439,15 +521,15 @@ async def test_scene_restore_rgb( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["rgb_color"] == initial_color - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_RGB_COLOR] == initial_color + assert light.attributes[ATTR_BRIGHTNESS] == 255 mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) # Activate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "sunrise"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"}, blocking=True, ) await hass.async_block_till_done() @@ -455,14 +537,14 @@ async def test_scene_restore_rgb( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] == "sunrise" + assert light.attributes[ATTR_EFFECT] == "sunrise" mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) # Deactivate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "none"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "none"}, blocking=True, ) await hass.async_block_till_done() @@ -470,9 +552,9 @@ async def test_scene_restore_rgb( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] is None - assert light.attributes["rgb_color"] == initial_color - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_EFFECT] is None + assert light.attributes[ATTR_RGB_COLOR] == initial_color + assert light.attributes[ATTR_BRIGHTNESS] == 255 async def test_scene_restore_temperature( @@ -505,8 +587,8 @@ async def test_scene_restore_temperature( # Set initial color await hass.services.async_call( - "light", - "turn_on", + LIGHT_DOMAIN, + SERVICE_TURN_ON, {"entity_id": light.entity_id, "color_temp_kelvin": initial_color}, blocking=True, ) @@ -520,9 +602,9 @@ async def test_scene_restore_temperature( # Activate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "sunrise"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"}, blocking=True, ) await hass.async_block_till_done() @@ -530,14 +612,14 @@ async def test_scene_restore_temperature( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] == "sunrise" + assert light.attributes[ATTR_EFFECT] == "sunrise" mock_govee_api.set_scene.assert_awaited_with(mock_govee_api.devices[0], "sunrise") # Deactivate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "none"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "none"}, blocking=True, ) await hass.async_block_till_done() @@ -545,7 +627,7 @@ async def test_scene_restore_temperature( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] is None + assert light.attributes[ATTR_EFFECT] is None assert light.attributes["color_temp_kelvin"] == initial_color @@ -577,16 +659,16 @@ async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> Non # Set initial color await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "rgb_color": initial_color}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_RGB_COLOR: initial_color}, blocking=True, ) await hass.async_block_till_done() await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255}, blocking=True, ) await hass.async_block_till_done() @@ -594,21 +676,20 @@ async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> Non light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["rgb_color"] == initial_color - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_RGB_COLOR] == initial_color + assert light.attributes[ATTR_BRIGHTNESS] == 255 mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) # Activate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "none"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "none"}, blocking=True, ) await hass.async_block_till_done() - light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] is None + assert light.attributes[ATTR_EFFECT] is None mock_govee_api.set_scene.assert_not_called() diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 461df19ebf8..30adae2fd2a 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -12,7 +12,7 @@ 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 +from tests.common import MockConfigEntry, get_schema_suggested_value from tests.typing import WebSocketGenerator @@ -201,17 +201,6 @@ async def test_config_flow_hides_members( assert entity_registry.async_get(f"{group_type}.three").hidden_by == hidden_by -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - @pytest.mark.parametrize( ("group_type", "member_state", "extra_options", "options_options"), [ @@ -269,7 +258,9 @@ async def test_options( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == group_type - assert get_suggested(result["data_schema"].schema, "entities") == members1 + assert ( + get_schema_suggested_value(result["data_schema"].schema, "entities") == members1 + ) assert "name" not in result["data_schema"].schema assert result["data_schema"].schema["entities"].config["exclude_entities"] == [ f"{group_type}.bed_room" @@ -316,8 +307,8 @@ async def test_options( assert result["type"] is FlowResultType.FORM assert result["step_id"] == group_type - assert get_suggested(result["data_schema"].schema, "entities") is None - assert get_suggested(result["data_schema"].schema, "name") is None + assert get_schema_suggested_value(result["data_schema"].schema, "entities") is None + assert get_schema_suggested_value(result["data_schema"].schema, "name") is None @pytest.mark.parametrize( diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py index 07678b031bc..5ec998ec82e 100644 --- a/tests/components/habitica/test_config_flow.py +++ b/tests/components/habitica/test_config_flow.py @@ -76,8 +76,9 @@ async def test_form_login(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> N assert "login" in result["menu_options"] assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "login"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "login"}, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} @@ -123,8 +124,9 @@ async def test_form_login_errors( assert result["type"] is FlowResultType.MENU assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "login"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "login"}, ) habitica.login.side_effect = raise_error @@ -156,7 +158,7 @@ async def test_form_login_errors( @pytest.mark.usefixtures("habitica") -async def test_form__already_configured( +async def test_form_already_configured( hass: HomeAssistant, config_entry: MockConfigEntry, ) -> None: @@ -171,13 +173,14 @@ async def test_form__already_configured( 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"], + {"next_step_id": "login"}, ) result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=MOCK_DATA_ADVANCED_STEP, + user_input=MOCK_DATA_LOGIN_STEP, ) assert result["type"] is FlowResultType.ABORT @@ -196,19 +199,14 @@ async def test_form_advanced(hass: HomeAssistant, mock_setup_entry: AsyncMock) - assert "advanced" in result["menu_options"] 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"], + {"next_step_id": "advanced"}, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} assert result["step_id"] == "advanced" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "advanced"} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_DATA_ADVANCED_STEP, @@ -249,8 +247,9 @@ async def test_form_advanced_errors( 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"], + {"next_step_id": "advanced"}, ) habitica.get_user.side_effect = raise_error @@ -298,8 +297,9 @@ async def test_form_advanced_already_configured( 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"], + {"next_step_id": "advanced"}, ) result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/harmony/test_init.py b/tests/components/harmony/test_init.py index 971983fc3b6..10befc40b8e 100644 --- a/tests/components/harmony/test_init.py +++ b/tests/components/harmony/test_init.py @@ -17,7 +17,7 @@ from .const import ( WATCH_TV_ACTIVITY_ID, ) -from tests.common import MockConfigEntry, mock_registry +from tests.common import MockConfigEntry, RegistryEntryWithDefaults, mock_registry async def test_unique_id_migration( @@ -33,35 +33,35 @@ async def test_unique_id_migration( hass, { # old format - ENTITY_WATCH_TV: er.RegistryEntry( + ENTITY_WATCH_TV: RegistryEntryWithDefaults( entity_id=ENTITY_WATCH_TV, unique_id="123443-Watch TV", platform="harmony", config_entry_id=entry.entry_id, ), # old format, activity name with - - ENTITY_NILE_TV: er.RegistryEntry( + ENTITY_NILE_TV: RegistryEntryWithDefaults( entity_id=ENTITY_NILE_TV, unique_id="123443-Nile-TV", platform="harmony", config_entry_id=entry.entry_id, ), # new format - ENTITY_PLAY_MUSIC: er.RegistryEntry( + ENTITY_PLAY_MUSIC: RegistryEntryWithDefaults( entity_id=ENTITY_PLAY_MUSIC, unique_id=f"activity_{PLAY_MUSIC_ACTIVITY_ID}", platform="harmony", config_entry_id=entry.entry_id, ), # old entity which no longer has a matching activity on the hub. skipped. - "switch.some_other_activity": er.RegistryEntry( + "switch.some_other_activity": RegistryEntryWithDefaults( entity_id="switch.some_other_activity", unique_id="123443-Some Other Activity", platform="harmony", config_entry_id=entry.entry_id, ), # select entity - ENTITY_SELECT: er.RegistryEntry( + ENTITY_SELECT: RegistryEntryWithDefaults( entity_id=ENTITY_SELECT, unique_id=f"{HUB_NAME}_activities", platform="harmony", diff --git a/tests/components/hassio/common.py b/tests/components/hassio/common.py index 82d3564440b..5cf7e80b191 100644 --- a/tests/components/hassio/common.py +++ b/tests/components/hassio/common.py @@ -151,8 +151,7 @@ def mock_addon_installed( def mock_addon_running(addon_store_info: AsyncMock, addon_info: AsyncMock) -> AsyncMock: """Mock add-on already running.""" - addon_store_info.return_value.available = True - addon_store_info.return_value.installed = True + mock_addon_installed(addon_store_info, addon_info) addon_info.return_value.state = "started" return addon_info diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index c9fbf1a7c56..ea38865ac5a 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -3,17 +3,16 @@ from collections.abc import Generator import os import re -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, patch from aiohasupervisor.models import AddonsStats, AddonState from aiohttp.test_utils import TestClient import pytest from homeassistant.auth.models import RefreshToken -from homeassistant.components.hassio.handler import HassIO, HassioAPIError +from homeassistant.components.hassio.handler import HassIO from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.setup import async_setup_component from . import SUPERVISOR_TOKEN @@ -32,70 +31,21 @@ def disable_security_filter() -> Generator[None]: @pytest.fixture -def hassio_env(supervisor_is_connected: AsyncMock) -> Generator[None]: - """Fixture to inject hassio env.""" - with ( - patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}), - patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}), - patch( - "homeassistant.components.hassio.HassIO.get_info", - Mock(side_effect=HassioAPIError()), - ), - ): - yield - - -@pytest.fixture -def hassio_stubs( - hassio_env: None, - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - supervisor_client: AsyncMock, -) -> RefreshToken: - """Create mock hassio http client.""" - with ( - patch( - "homeassistant.components.hassio.HassIO.update_hass_api", - return_value={"result": "ok"}, - ) as hass_api, - patch( - "homeassistant.components.hassio.HassIO.update_hass_timezone", - return_value={"result": "ok"}, - ), - patch( - "homeassistant.components.hassio.HassIO.get_info", - side_effect=HassioAPIError(), - ), - patch( - "homeassistant.components.hassio.HassIO.get_ingress_panels", - return_value={"panels": []}, - ), - patch( - "homeassistant.components.hassio.issues.SupervisorIssues.setup", - ), - ): - hass.loop.run_until_complete(async_setup_component(hass, "hassio", {})) - - return hass_api.call_args[0][1] - - -@pytest.fixture -def hassio_client( +async def hassio_client( hassio_stubs: RefreshToken, hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> TestClient: """Return a Hass.io HTTP client.""" - return hass.loop.run_until_complete(hass_client()) + return await hass_client() @pytest.fixture -def hassio_noauth_client( +async def hassio_noauth_client( hassio_stubs: RefreshToken, hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, ) -> TestClient: """Return a Hass.io HTTP client without auth.""" - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return await aiohttp_client(hass.http.app) @pytest.fixture diff --git a/tests/components/hassio/snapshots/test_config.ambr b/tests/components/hassio/snapshots/test_config.ambr new file mode 100644 index 00000000000..905c4155184 --- /dev/null +++ b/tests/components/hassio/snapshots/test_config.ambr @@ -0,0 +1,46 @@ +# serializer version: 1 +# name: test_load_config_store[storage_data0] + dict({ + 'hassio_user': '766572795f764572b95f72616e646f6d', + 'update_config': dict({ + 'add_on_backup_before_update': False, + 'add_on_backup_retain_copies': 1, + 'core_backup_before_update': False, + }), + }) +# --- +# name: test_load_config_store[storage_data1] + dict({ + 'hassio_user': '00112233445566778899aabbccddeeff', + 'update_config': dict({ + 'add_on_backup_before_update': False, + 'add_on_backup_retain_copies': 1, + 'core_backup_before_update': False, + }), + }) +# --- +# name: test_load_config_store[storage_data2] + dict({ + 'hassio_user': '00112233445566778899aabbccddeeff', + 'update_config': dict({ + 'add_on_backup_before_update': True, + 'add_on_backup_retain_copies': 2, + 'core_backup_before_update': True, + }), + }) +# --- +# name: test_save_config_store + dict({ + 'data': dict({ + 'hassio_user': '766572795f764572b95f72616e646f6d', + 'update_config': dict({ + 'add_on_backup_before_update': False, + 'add_on_backup_retain_copies': 1, + 'core_backup_before_update': False, + }), + }), + 'key': 'hassio', + 'minor_version': 1, + 'version': 1, + }) +# --- diff --git a/tests/components/hassio/snapshots/test_websocket_api.ambr b/tests/components/hassio/snapshots/test_websocket_api.ambr new file mode 100644 index 00000000000..e3ff6c978c1 --- /dev/null +++ b/tests/components/hassio/snapshots/test_websocket_api.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_read_update_config + dict({ + 'id': 1, + 'result': dict({ + 'add_on_backup_before_update': False, + 'add_on_backup_retain_copies': 1, + 'core_backup_before_update': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_read_update_config.1 + dict({ + 'id': 2, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_read_update_config.2 + dict({ + 'id': 3, + 'result': dict({ + 'add_on_backup_before_update': True, + 'add_on_backup_retain_copies': 2, + 'core_backup_before_update': True, + }), + 'success': True, + 'type': 'result', + }) +# --- diff --git a/tests/components/hassio/test_config.py b/tests/components/hassio/test_config.py new file mode 100644 index 00000000000..86a97cc4a0a --- /dev/null +++ b/tests/components/hassio/test_config.py @@ -0,0 +1,182 @@ +"""Test websocket API.""" + +from typing import Any +from unittest.mock import AsyncMock, patch +from uuid import UUID + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.auth.const import GROUP_ID_ADMIN +from homeassistant.components.hassio.const import DATA_CONFIG_STORE, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockUser +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import WebSocketGenerator + +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} + + +@pytest.fixture(autouse=True) +def mock_all( + aioclient_mock: AiohttpClientMocker, + supervisor_is_connected: AsyncMock, + resolution_info: AsyncMock, + addon_info: AsyncMock, +) -> None: + """Mock all setup requests.""" + aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) + aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/info", + json={ + "result": "ok", + "data": {"supervisor": "222", "homeassistant": "0.110.0", "hassos": None}, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/host/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "data": { + "chassis": "vm", + "operating_system": "Debian GNU/Linux 10 (buster)", + "kernel": "4.19.0-6-amd64", + }, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/core/info", + json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/os/info", + json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/info", + json={ + "result": "ok", + "data": { + "version": "1.0.0", + "version_latest": "1.0.0", + "auto_update": True, + "addons": [ + { + "name": "test", + "state": "started", + "slug": "test", + "installed": True, + "update_available": True, + "icon": False, + "version": "2.0.0", + "version_latest": "2.0.1", + "repository": "core", + "url": "https://github.com/home-assistant/addons/test", + }, + ], + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} + ) + aioclient_mock.get( + "http://127.0.0.1/network/info", + json={ + "result": "ok", + "data": { + "host_internet": True, + "supervisor_internet": True, + }, + }, + ) + + +@pytest.mark.usefixtures("hassio_env") +@pytest.mark.parametrize( + "storage_data", + [ + {}, + { + "hassio": { + "data": { + "hassio_user": "00112233445566778899aabbccddeeff", + "update_config": { + "add_on_backup_before_update": False, + "add_on_backup_retain_copies": 1, + "core_backup_before_update": False, + }, + }, + "key": "hassio", + "minor_version": 1, + "version": 1, + } + }, + { + "hassio": { + "data": { + "hassio_user": "00112233445566778899aabbccddeeff", + "update_config": { + "add_on_backup_before_update": True, + "add_on_backup_retain_copies": 2, + "core_backup_before_update": True, + }, + }, + "key": "hassio", + "minor_version": 1, + "version": 1, + } + }, + ], +) +async def test_load_config_store( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + hass_storage: dict[str, Any], + storage_data: dict[str, dict[str, Any]], + snapshot: SnapshotAssertion, +) -> None: + """Test loading the config store.""" + hass_storage.update(storage_data) + + user = MockUser(id="00112233445566778899aabbccddeeff", system_generated=True) + user.add_to_hass(hass) + await hass.auth.async_create_refresh_token(user) + await hass.auth.async_update_user(user, group_ids=[GROUP_ID_ADMIN]) + + with ( + patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0), + patch("uuid.uuid4", return_value=UUID(bytes=b"very_very_random", version=4)), + ): + assert await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert hass.data[DATA_CONFIG_STORE].data.to_dict() == snapshot + + +@pytest.mark.usefixtures("hassio_env") +async def test_save_config_store( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + hass_storage: dict[str, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test saving the config store.""" + with ( + patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0), + patch("uuid.uuid4", return_value=UUID(bytes=b"very_very_random", version=4)), + ): + assert await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert hass_storage[DOMAIN] == snapshot diff --git a/tests/components/hassio/test_ingress.py b/tests/components/hassio/test_ingress.py index 805b5292edb..069abaa8513 100644 --- a/tests/components/hassio/test_ingress.py +++ b/tests/components/hassio/test_ingress.py @@ -269,6 +269,49 @@ async def test_ingress_request_options( assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO] +@pytest.mark.parametrize( + "build_type", + [ + ("a3_vl", "test/beer/ping?index=1"), + ("core", "index.html"), + ("local", "panel/config"), + ("jk_921", "editor.php?idx=3&ping=5"), + ("fsadjf10312", ""), + ], +) +async def test_ingress_request_head( + hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker +) -> None: + """Test no auth needed for .""" + aioclient_mock.head( + f"http://127.0.0.1/ingress/{build_type[0]}/{build_type[1]}", + text="test", + ) + + resp = await hassio_noauth_client.head( + f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", + headers={"X-Test-Header": "beer"}, + ) + + # Check we got right response + assert resp.status == HTTPStatus.OK + body = await resp.text() + assert body == "" # head does not return a body + + # Check we forwarded command + assert len(aioclient_mock.mock_calls) == 1 + assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3] + assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress" + assert ( + aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] + == f"/api/hassio_ingress/{build_type[0]}" + ) + assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer" + assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR] + assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST] + assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO] + + @pytest.mark.parametrize( "build_type", [ diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 5c11370ae74..d34aed608fb 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -17,16 +17,16 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI from homeassistant.components.hassio import ( ADDONS_COORDINATOR, DOMAIN, - STORAGE_KEY, get_core_info, get_supervisor_ip, hostname_from_addon_slug, is_hassio as deprecated_is_hassio, ) +from homeassistant.components.hassio.config import STORAGE_KEY from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.setup import async_setup_component @@ -228,7 +228,7 @@ async def test_setup_api_ping( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 assert get_core_info(hass)["version_latest"] == "1.0.0" assert is_hassio(hass) @@ -275,7 +275,7 @@ async def test_setup_api_push_api_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 assert not aioclient_mock.mock_calls[0][2]["ssl"] assert aioclient_mock.mock_calls[0][2]["port"] == 9999 assert "watchdog" not in aioclient_mock.mock_calls[0][2] @@ -296,7 +296,7 @@ async def test_setup_api_push_api_data_server_host( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 assert not aioclient_mock.mock_calls[0][2]["ssl"] assert aioclient_mock.mock_calls[0][2]["port"] == 9999 assert not aioclient_mock.mock_calls[0][2]["watchdog"] @@ -309,12 +309,15 @@ async def test_setup_api_push_api_data_default( supervisor_client: AsyncMock, ) -> None: """Test setup with API push default data.""" - with patch.dict(os.environ, MOCK_ENVIRON): + with ( + patch.dict(os.environ, MOCK_ENVIRON), + patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0), + ): result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}}) await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 assert not aioclient_mock.mock_calls[0][2]["ssl"] assert aioclient_mock.mock_calls[0][2]["port"] == 8123 refresh_token = aioclient_mock.mock_calls[0][2]["refresh_token"] @@ -395,13 +398,13 @@ async def test_setup_api_existing_hassio_user( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 assert not aioclient_mock.mock_calls[0][2]["ssl"] assert aioclient_mock.mock_calls[0][2]["port"] == 8123 assert aioclient_mock.mock_calls[0][2]["refresh_token"] == token.token -async def test_setup_core_push_timezone( +async def test_setup_core_push_config( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, supervisor_client: AsyncMock, @@ -414,13 +417,14 @@ async def test_setup_core_push_timezone( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 assert aioclient_mock.mock_calls[1][2]["timezone"] == "testzone" with patch("homeassistant.util.dt.set_default_time_zone"): - await hass.config.async_update(time_zone="America/New_York") + await hass.config.async_update(time_zone="America/New_York", country="US") await hass.async_block_till_done() assert aioclient_mock.mock_calls[-1][2]["timezone"] == "America/New_York" + assert aioclient_mock.mock_calls[-1][2]["country"] == "US" async def test_setup_hassio_no_additional_data( @@ -437,7 +441,7 @@ async def test_setup_hassio_no_additional_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" @@ -470,7 +474,6 @@ async def test_service_register(hass: HomeAssistant) -> None: assert hass.services.has_service("hassio", "addon_start") assert hass.services.has_service("hassio", "addon_stop") assert hass.services.has_service("hassio", "addon_restart") - assert hass.services.has_service("hassio", "addon_update") assert hass.services.has_service("hassio", "addon_stdin") assert hass.services.has_service("hassio", "host_shutdown") assert hass.services.has_service("hassio", "host_reboot") @@ -489,7 +492,6 @@ async def test_service_calls( supervisor_client: AsyncMock, addon_installed: AsyncMock, supervisor_is_connected: AsyncMock, - issue_registry: ir.IssueRegistry, ) -> None: """Call service and check the API calls behind that.""" supervisor_is_connected.side_effect = SupervisorError @@ -516,21 +518,19 @@ async def test_service_calls( await hass.services.async_call("hassio", "addon_start", {"addon": "test"}) await hass.services.async_call("hassio", "addon_stop", {"addon": "test"}) await hass.services.async_call("hassio", "addon_restart", {"addon": "test"}) - await hass.services.async_call("hassio", "addon_update", {"addon": "test"}) - assert (DOMAIN, "update_service_deprecated") in issue_registry.issues await hass.services.async_call( "hassio", "addon_stdin", {"addon": "test", "input": "test"} ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 25 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 22 assert aioclient_mock.mock_calls[-1][2] == "test" await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 27 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 24 await hass.services.async_call("hassio", "backup_full", {}) await hass.services.async_call( @@ -545,7 +545,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 29 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 26 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "homeassistant": True, @@ -570,7 +570,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 31 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 28 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -589,7 +589,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 32 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 29 assert aioclient_mock.mock_calls[-1][2] == { "name": "backup_name", "location": "backup_share", @@ -605,7 +605,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 33 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 30 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "location": None, @@ -624,7 +624,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 35 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 32 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "location": None, @@ -1070,7 +1070,7 @@ async def test_setup_hardware_integration( await hass.async_block_till_done(wait_background_tasks=True) assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index a3718454538..6ecc2b44244 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -5,8 +5,16 @@ import os from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from aiohasupervisor import SupervisorBadRequestError, SupervisorError -from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate +from aiohasupervisor import ( + SupervisorBadRequestError, + SupervisorError, + SupervisorNotFoundError, +) +from aiohasupervisor.models import ( + HomeAssistantUpdateOptions, + OSUpdate, + StoreAddonUpdate, +) import pytest from homeassistant.components.backup import BackupManagerError, ManagerBackup @@ -475,13 +483,123 @@ async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> N await hass.async_block_till_done() supervisor_client.os.update.return_value = None - await hass.services.async_call( - "update", - "install", - {"entity_id": "update.home_assistant_operating_system_update"}, - blocking=True, - ) - supervisor_client.os.update.assert_called_once() + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_operating_system_update"}, + blocking=True, + ) + mock_create_backup.assert_not_called() + supervisor_client.os.update.assert_called_once_with(OSUpdate(version=None)) + + +@pytest.mark.parametrize( + ("commands", "default_mount", "expected_kwargs"), + [ + ( + [], + None, + { + "agent_ids": ["hassio.local"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [], + "my_nas", + { + "agent_ids": ["hassio.my_nas"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "name": "cool_backup", + "password": "hunter2", + }, + }, + ], + None, + { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "include_homeassistant": True, + "name": "cool_backup", + "password": "hunter2", + "with_automatic_settings": True, + }, + ), + ], +) +async def test_update_os_with_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + commands: list[dict[str, Any]], + default_mount: str | None, + expected_kwargs: dict[str, Any], +) -> None: + """Test updating OS update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await setup_backup_integration(hass) + + client = await hass_ws_client(hass) + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + supervisor_client.os.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = default_mount + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + { + "entity_id": "update.home_assistant_operating_system_update", + "backup": True, + }, + blocking=True, + ) + mock_create_backup.assert_called_once_with(**expected_kwargs) + supervisor_client.os.update.assert_called_once_with(OSUpdate(version=None)) async def test_update_core(hass: HomeAssistant, supervisor_client: AsyncMock) -> None: @@ -663,7 +781,7 @@ async def test_update_addon_with_error( update_addon.side_effect = SupervisorError with pytest.raises(HomeAssistantError, match=r"^Error updating test:"): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.test_update"}, @@ -711,7 +829,7 @@ async def test_update_addon_with_backup_and_error( ), pytest.raises(HomeAssistantError, match=message), ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.test_update", "backup": True}, @@ -738,7 +856,7 @@ async def test_update_os_with_error( with pytest.raises( HomeAssistantError, match=r"^Error updating Home Assistant Operating System:" ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_operating_system_update"}, @@ -746,6 +864,43 @@ async def test_update_os_with_error( ) +async def test_update_os_with_backup_and_error( + hass: HomeAssistant, + supervisor_client: AsyncMock, +) -> None: + """Test updating OS update entity with error.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await setup_backup_integration(hass) + + supervisor_client.os.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + side_effect=BackupManagerError, + ), + pytest.raises(HomeAssistantError, match=r"^Error creating backup:"), + ): + await hass.services.async_call( + "update", + "install", + { + "entity_id": "update.home_assistant_operating_system_update", + "backup": True, + }, + blocking=True, + ) + + async def test_update_supervisor_with_error( hass: HomeAssistant, supervisor_client: AsyncMock ) -> None: @@ -765,7 +920,7 @@ async def test_update_supervisor_with_error( with pytest.raises( HomeAssistantError, match=r"^Error updating Home Assistant Supervisor:" ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_supervisor_update"}, @@ -792,7 +947,7 @@ async def test_update_core_with_error( with pytest.raises( HomeAssistantError, match=r"^Error updating Home Assistant Core:" ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_core_update"}, @@ -826,7 +981,7 @@ async def test_update_core_with_backup_and_error( ), pytest.raises(HomeAssistantError, match=r"^Error creating backup:"), ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_core_update", "backup": True}, @@ -836,6 +991,7 @@ async def test_update_core_with_backup_and_error( async def test_release_notes_between_versions( hass: HomeAssistant, + addon_changelog: AsyncMock, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: @@ -843,12 +999,10 @@ async def test_release_notes_between_versions( config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) config_entry.add_to_hass(hass) + addon_changelog.return_value = "# 2.0.1\nNew updates\n# 2.0.0\nOld updates" + with ( patch.dict(os.environ, MOCK_ENVIRON), - patch( - "homeassistant.components.hassio.coordinator.get_addons_changelogs", - return_value={"test": "# 2.0.1\nNew updates\n# 2.0.0\nOld updates"}, - ), ): result = await async_setup_component( hass, @@ -875,6 +1029,7 @@ async def test_release_notes_between_versions( async def test_release_notes_full( hass: HomeAssistant, + addon_changelog: AsyncMock, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: @@ -882,12 +1037,11 @@ async def test_release_notes_full( config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) config_entry.add_to_hass(hass) + full_changelog = "# 2.0.0\nNew updates\n# 2.0.0\nOld updates" + addon_changelog.return_value = full_changelog + with ( patch.dict(os.environ, MOCK_ENVIRON), - patch( - "homeassistant.components.hassio.coordinator.get_addons_changelogs", - return_value={"test": "# 2.0.0\nNew updates\n# 2.0.0\nOld updates"}, - ), ): result = await async_setup_component( hass, @@ -911,9 +1065,21 @@ async def test_release_notes_full( assert "Old updates" in result["result"] assert "New updates" in result["result"] + # Update entity without update should returns full changelog + await client.send_json( + { + "id": 2, + "type": "update/release_notes", + "entity_id": "update.test2_update", + } + ) + result = await client.receive_json() + assert result["result"] == full_changelog + async def test_not_release_notes( hass: HomeAssistant, + addon_changelog: AsyncMock, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: @@ -921,12 +1087,10 @@ async def test_not_release_notes( config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) config_entry.add_to_hass(hass) + addon_changelog.side_effect = SupervisorNotFoundError() + with ( patch.dict(os.environ, MOCK_ENVIRON), - patch( - "homeassistant.components.hassio.coordinator.get_addons_changelogs", - return_value={"test": None}, - ), ): result = await async_setup_component( hass, diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index b695cc1794a..cbf664d0e49 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiohasupervisor import SupervisorError from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest +from syrupy import SnapshotAssertion from homeassistant.components.backup import BackupManagerError, ManagerBackup @@ -42,6 +43,7 @@ def mock_all( aioclient_mock: AiohttpClientMocker, supervisor_is_connected: AsyncMock, resolution_info: AsyncMock, + addon_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) @@ -468,13 +470,15 @@ async def test_update_addon_with_backup( @pytest.mark.parametrize( - ("backups", "removed_backups"), + ("ws_commands", "backups", "removed_backups"), [ ( + [], {}, [], ), ( + [], { "backup-1": MagicMock( agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, @@ -519,6 +523,52 @@ async def test_update_addon_with_backup( }, ["backup-5"], ), + ( + [{"type": "hassio/update/config/update", "add_on_backup_retain_copies": 2}], + { + "backup-1": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-6": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-12T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + }, + [], + ), ], ) async def test_update_addon_with_backup_removes_old_backups( @@ -526,6 +576,7 @@ async def test_update_addon_with_backup_removes_old_backups( hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, update_addon: AsyncMock, + ws_commands: list[dict[str, Any]], backups: dict[str, ManagerBackup], removed_backups: list[str], ) -> None: @@ -543,6 +594,12 @@ async def test_update_addon_with_backup_removes_old_backups( await setup_backup_integration(hass) client = await hass_ws_client(hass) + + for command in ws_commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + supervisor_client.mounts.info.return_value.default_backup_mount = None with ( patch( @@ -848,12 +905,38 @@ async def test_update_core_with_backup_and_error( side_effect=BackupManagerError, ), ): - await client.send_json_auto_id( - {"type": "hassio/update/addon", "addon": "test", "backup": True} - ) + await client.send_json_auto_id({"type": "hassio/update/core", "backup": True}) result = await client.receive_json() assert not result["success"] assert result["error"] == { "code": "home_assistant_error", "message": "Error creating backup: ", } + + +@pytest.mark.usefixtures("hassio_env") +async def test_read_update_config( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test read and update config.""" + assert await async_setup_component(hass, "hassio", {}) + websocket_client = await hass_ws_client(hass) + + await websocket_client.send_json_auto_id({"type": "hassio/update/config/info"}) + assert await websocket_client.receive_json() == snapshot + + await websocket_client.send_json_auto_id( + { + "type": "hassio/update/config/update", + "add_on_backup_before_update": True, + "add_on_backup_retain_copies": 2, + "core_backup_before_update": True, + } + ) + assert await websocket_client.receive_json() == snapshot + + await websocket_client.send_json_auto_id({"type": "hassio/update/config/info"}) + assert await websocket_client.receive_json() == snapshot diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index 34eba8a9c76..edc128f2f78 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -39,9 +39,12 @@ class MockHeos(Heos): self.player_clear_queue: AsyncMock = AsyncMock() self.player_get_queue: AsyncMock = AsyncMock() self.player_get_quick_selects: AsyncMock = AsyncMock() + self.player_move_queue_item: AsyncMock = AsyncMock() self.player_play_next: AsyncMock = AsyncMock() self.player_play_previous: AsyncMock = AsyncMock() + self.player_play_queue: AsyncMock = AsyncMock() self.player_play_quick_select: AsyncMock = AsyncMock() + self.player_remove_from_queue: AsyncMock = AsyncMock() self.player_set_mute: AsyncMock = AsyncMock() self.player_set_play_mode: AsyncMock = AsyncMock() self.player_set_play_state: AsyncMock = AsyncMock() diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index d366a7f6317..68ab24c6479 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -3,10 +3,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'track', 'media_content_id': 'heos://media/1/station?name=Today%27s+Hits+Radio&image_url=&playable=True&browsable=False&media_id=123456789', @@ -28,6 +30,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ ]), 'children_media_class': None, @@ -43,10 +46,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'music', 'media_content_id': 'media-source://media_source/local/test.mp3', @@ -68,10 +73,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'directory', 'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user', @@ -82,6 +89,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'directory', 'media_content_id': 'heos://media/3/music_service?name=TuneIn&image_url=&available=False', @@ -92,6 +100,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': 'music', 'media_class': 'directory', 'media_content_id': 'media-source://media_source/local/.', @@ -113,10 +122,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'directory', 'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user', @@ -127,6 +138,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'directory', 'media_content_id': 'heos://media/3/music_service?name=TuneIn&image_url=&available=False', @@ -148,6 +160,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ ]), 'children_media_class': 'directory', diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 474d606b5b1..30d17f4a8ca 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -27,11 +27,15 @@ from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.heos.const import ( + ATTR_DESTINATION_POSITION, + ATTR_QUEUE_IDS, DOMAIN, SERVICE_GET_QUEUE, SERVICE_GROUP_VOLUME_DOWN, SERVICE_GROUP_VOLUME_SET, SERVICE_GROUP_VOLUME_UP, + SERVICE_MOVE_QUEUE_ITEM, + SERVICE_REMOVE_FROM_QUEUE, ) from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, @@ -1321,6 +1325,51 @@ async def test_play_media_music_source_url( controller.play_url.assert_called_once() +async def test_play_media_queue( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, +) -> None: + """Test the play media service with type queue.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "queue", + ATTR_MEDIA_CONTENT_ID: "2", + }, + blocking=True, + ) + controller.player_play_queue.assert_called_once_with(1, 2) + + +async def test_play_media_queue_invalid( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the play media service with an invalid queue id.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to play media: Invalid queue id 'Invalid'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "queue", + ATTR_MEDIA_CONTENT_ID: "Invalid", + }, + blocking=True, + ) + assert controller.player_play_queue.call_count == 0 + + async def test_browse_media_root( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -1722,3 +1771,60 @@ async def test_get_queue( ) controller.player_get_queue.assert_called_once_with(1, None, None) assert response == snapshot + + +async def test_remove_from_queue( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the get queue service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_REMOVE_FROM_QUEUE, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_QUEUE_IDS: [1, "2"]}, + blocking=True, + ) + controller.player_remove_from_queue.assert_called_once_with(1, [1, 2]) + + +async def test_move_queue_item_queue( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the move queue service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_MOVE_QUEUE_ITEM, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_QUEUE_IDS: [1, "2"], + ATTR_DESTINATION_POSITION: 10, + }, + blocking=True, + ) + controller.player_move_queue_item.assert_called_once_with(1, [1, 2], 10) + + +async def test_move_queue_item_queue_error_raises( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test move queue raises error when failed.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.player_move_queue_item.side_effect = HeosError("error") + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to move queue item: error"), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_MOVE_QUEUE_ITEM, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_QUEUE_IDS: [1, "2"], + ATTR_DESTINATION_POSITION: 10, + }, + blocking=True, + ) diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index e2dba1b9355..ee426cf3048 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -969,6 +969,135 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_mul assert hass.states.get("sensor.sensor4").state == "87.5" +async def test_start_from_history_then_watch_state_changes_sliding( + recorder_mock: Recorder, + hass: HomeAssistant, +) -> None: + """Test we startup from history and switch to watching state changes. + + With a sliding window, history_stats does not requery the recorder. + """ + await hass.config.async_set_time_zone("UTC") + utcnow = dt_util.utcnow() + start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) + time = start_time + + def _fake_states(*args, **kwargs): + return { + "binary_sensor.state": [ + ha.State( + "binary_sensor.state", + "off", + last_changed=start_time - timedelta(hours=1), + last_updated=start_time - timedelta(hours=1), + ), + ] + } + + with ( + patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ), + freeze_time(start_time), + ): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.state", + "name": f"sensor{i}", + "state": "on", + "end": "{{ utcnow() }}", + "duration": {"hours": 1}, + "type": sensor_type, + } + for i, sensor_type in enumerate(["time", "ratio", "count"]) + ] + }, + ) + await hass.async_block_till_done() + + for i in range(3): + await async_update_entity(hass, f"sensor.sensor{i}") + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.0" + assert hass.states.get("sensor.sensor1").state == "0.0" + assert hass.states.get("sensor.sensor2").state == "0" + + with freeze_time(time): + hass.states.async_set("binary_sensor.state", "on") + await hass.async_block_till_done() + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.0" + assert hass.states.get("sensor.sensor1").state == "0.0" + assert hass.states.get("sensor.sensor2").state == "1" + + # After sensor has been on for 15 minutes, check state + time += timedelta(minutes=15) # 00:15 + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.25" + assert hass.states.get("sensor.sensor1").state == "25.0" + assert hass.states.get("sensor.sensor2").state == "1" + + with freeze_time(time): + hass.states.async_set("binary_sensor.state", "off") + await hass.async_block_till_done() + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + time += timedelta(minutes=30) # 00:45 + + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.25" + assert hass.states.get("sensor.sensor1").state == "25.0" + assert hass.states.get("sensor.sensor2").state == "1" + + time += timedelta(minutes=20) # 01:05 + + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + # Sliding window will have started to erase the initial on period, so now it will only be on for 10 minutes + assert hass.states.get("sensor.sensor0").state == "0.17" + assert hass.states.get("sensor.sensor1").state == "16.7" + assert hass.states.get("sensor.sensor2").state == "1" + + time += timedelta(minutes=5) # 01:10 + + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + # Sliding window will continue to erase the initial on period, so now it will only be on for 5 minutes + assert hass.states.get("sensor.sensor0").state == "0.08" + assert hass.states.get("sensor.sensor1").state == "8.3" + assert hass.states.get("sensor.sensor2").state == "1" + + time += timedelta(minutes=10) # 01:20 + + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.0" + assert hass.states.get("sensor.sensor1").state == "0.0" + assert hass.states.get("sensor.sensor2").state == "0" + + async def test_does_not_work_into_the_future( recorder_mock: Recorder, hass: HomeAssistant ) -> None: @@ -1366,10 +1495,6 @@ async def test_measure_from_end_going_backwards( past_next_update = start_time + timedelta(minutes=30) with ( - patch( - "homeassistant.components.recorder.history.state_changes_during_period", - _fake_states, - ), freeze_time(past_next_update), ): async_fire_time_changed(hass, past_next_update) @@ -1526,29 +1651,10 @@ async def test_state_change_during_window_rollover( assert hass.states.get("sensor.sensor1").state == "11.98" - # One minute has passed and the time has now rolled over into a new day, resetting the recorder window. The sensor will then query the database for updates, - # and will see that the sensor is ON starting from midnight. + # One minute has passed and the time has now rolled over into a new day, resetting the recorder window. + # The sensor will be ON since midnight. t3 = t2 + timedelta(minutes=1) - - def _fake_states_t3(*args, **kwargs): - return { - "binary_sensor.state": [ - ha.State( - "binary_sensor.state", - "on", - last_changed=t3.replace(hour=0, minute=0, second=0, microsecond=0), - last_updated=t3.replace(hour=0, minute=0, second=0, microsecond=0), - ), - ] - } - - with ( - patch( - "homeassistant.components.recorder.history.state_changes_during_period", - _fake_states_t3, - ), - freeze_time(t3), - ): + with freeze_time(t3): # The sensor turns off around this time, before the sensor does its normal polled update. hass.states.async_set("binary_sensor.state", "off") await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 21cd236b1a8..516701f2360 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -46,7 +46,11 @@ from tests.common import MockConfigEntry, load_fixture CLIENT_ID = "1234" CLIENT_SECRET = "5678" -FAKE_ACCESS_TOKEN = "some-access-token" +FAKE_ACCESS_TOKEN = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ" + ".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" +) FAKE_REFRESH_TOKEN = "some-refresh-token" FAKE_AUTH_IMPL = "conftest-imported-cred" @@ -84,7 +88,8 @@ def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: "auth_implementation": FAKE_AUTH_IMPL, "token": token_entry, }, - minor_version=2, + minor_version=3, + unique_id="1234567890", ) @@ -101,6 +106,19 @@ def mock_config_entry_v1_1(token_entry: dict[str, Any]) -> MockConfigEntry: ) +@pytest.fixture(name="config_entry_v1_2") +def mock_config_entry_v1_2(token_entry: dict[str, Any]) -> MockConfigEntry: + """Fixture for a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": FAKE_AUTH_IMPL, + "token": token_entry, + }, + minor_version=2, + ) + + @pytest.fixture async def setup_credentials(hass: HomeAssistant) -> None: """Fixture to setup credentials.""" diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index 672d04e108b..509003ad931 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -1,7 +1,6 @@ """Tests for home_connect binary_sensor entities.""" from collections.abc import Awaitable, Callable -from http import HTTPStatus from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( @@ -16,17 +15,11 @@ from aiohomeconnect.model import ( from aiohomeconnect.model.error import HomeConnectApiError import pytest -from homeassistant.components import automation, script -from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE_CLOSED, - BSH_DOOR_STATE_LOCKED, - BSH_DOOR_STATE_OPEN, DOMAIN, REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( STATE_OFF, @@ -37,11 +30,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.issue_registry as ir -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator @pytest.fixture @@ -202,7 +192,6 @@ async def test_binary_sensors_entity_availability( ) -> None: """Test if binary sensor entities availability are based on the appliance connection state.""" entity_ids = [ - "binary_sensor.washer_door", "binary_sensor.washer_remote_control", ] assert config_entry.state == ConfigEntryState.NOT_LOADED @@ -245,57 +234,6 @@ async def test_binary_sensors_entity_availability( assert state.state != STATE_UNAVAILABLE -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) -@pytest.mark.parametrize( - ("value", "expected"), - [ - (BSH_DOOR_STATE_CLOSED, "off"), - (BSH_DOOR_STATE_LOCKED, "off"), - (BSH_DOOR_STATE_OPEN, "on"), - ("", STATE_UNKNOWN), - ], -) -async def test_binary_sensors_door_states( - appliance: HomeAppliance, - expected: str, - value: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Tests for Appliance door states.""" - entity_id = "binary_sensor.washer_door" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - await client.add_events( - [ - EventMessage( - appliance.ha_id, - EventType.STATUS, - ArrayOfEvents( - [ - Event( - key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, - raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, - timestamp=0, - level="", - handling="", - value=value, - ) - ], - ), - ) - ] - ) - await hass.async_block_till_done() - assert hass.states.is_state(entity_id, expected) - - @pytest.mark.parametrize( ("entity_id", "event_key", "event_value_update", "expected", "appliance"), [ @@ -426,141 +364,3 @@ async def test_connected_sensor_functionality( await hass.async_block_till_done() assert hass.states.is_state(entity_id, STATE_ON) - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_create_door_binary_sensor_deprecation_issue( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - issue_registry: ir.IssueRegistry, -) -> None: - """Test that we create an issue when an automation or script is using a door binary sensor entity.""" - entity_id = "binary_sensor.washer_door" - issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" - - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": { - "entity_id": "automation.test", - }, - }, - } - }, - ) - assert await async_setup_component( - hass, - script.DOMAIN, - { - script.DOMAIN: { - "test": { - "sequence": [ - { - "condition": "state", - "entity_id": entity_id, - "state": "on", - }, - ], - } - } - }, - ) - - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - assert automations_with_entity(hass, entity_id)[0] == "automation.test" - assert scripts_with_entity(hass, entity_id)[0] == "script.test" - - assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue(DOMAIN, issue_id) - - await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_door_binary_sensor_deprecation_issue_fix( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - issue_registry: ir.IssueRegistry, - hass_client: ClientSessionGenerator, -) -> None: - """Test that we create an issue when an automation or script is using a door binary sensor entity.""" - entity_id = "binary_sensor.washer_door" - issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" - - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": { - "entity_id": "automation.test", - }, - }, - } - }, - ) - assert await async_setup_component( - hass, - script.DOMAIN, - { - script.DOMAIN: { - "test": { - "sequence": [ - { - "condition": "state", - "entity_id": entity_id, - "state": "on", - }, - ], - } - } - }, - ) - - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - assert automations_with_entity(hass, entity_id)[0] == "automation.test" - assert scripts_with_entity(hass, entity_id)[0] == "script.test" - - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue - - _client = await hass_client() - resp = await _client.post( - "/api/repairs/issues/fix", - json={"handler": DOMAIN, "issue_id": issue.issue_id}, - ) - assert resp.status == HTTPStatus.OK - flow_id = (await resp.json())["flow_id"] - resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index c35678e4e5f..19182a12194 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -13,10 +13,13 @@ from homeassistant.components.application_credentials import ( async_import_client_credential, ) from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from .conftest import FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN + from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -64,8 +67,8 @@ async def test_full_flow( aioclient_mock.post( OAUTH2_TOKEN, json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", + "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": FAKE_ACCESS_TOKEN, "type": "Bearer", "expires_in": 60, }, @@ -77,23 +80,64 @@ async def test_full_flow( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "1234567890") assert len(mock_setup_entry.mock_calls) == 1 -async def test_prevent_multiple_config_entries( +@pytest.mark.usefixtures("current_request_with_host") +async def test_prevent_reconfiguring_same_account( hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, config_entry: MockConfigEntry, ) -> None: - """Test we only allow one config entry.""" + """Test we only allow one config entry per account.""" config_entry.add_to_hass(hass) + assert await setup.async_setup_component(hass, "home_connect", {}) + + await async_import_client_credential( + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) + ) + result = await hass.config_entries.flow.async_init( "home_connect", context={"source": config_entries.SOURCE_USER} ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": FAKE_ACCESS_TOKEN, + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() assert result["type"] == "abort" - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == "already_configured" @pytest.mark.usefixtures("current_request_with_host") @@ -129,8 +173,8 @@ async def test_reauth_flow( aioclient_mock.post( OAUTH2_TOKEN, json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", + "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": FAKE_ACCESS_TOKEN, "type": "Bearer", "expires_in": 60, }, @@ -142,9 +186,61 @@ async def test_reauth_flow( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "1234567890") + assert entry + assert entry.state is ConfigEntryState.LOADED assert len(mock_setup_entry.mock_calls) == 1 - await hass.async_block_till_done() assert result["type"] == FlowResultType.ABORT assert result["reason"] == "reauth_successful" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow_with_different_account( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test reauth flow.""" + 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"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + _client = await hass_client_no_auth() + resp = await _client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJzdWIiOiJBQkNERSIsIm5hbWUiOiJKb2huIERvZSIsImFkbWluIjp0cnVlLCJpYXQiOjE1MTYyMzkwMjJ9" + ".Q9z9JT4qgNg9Y9ki61jzvd69j043GFWJk9HNYosAPzs" + ), + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "wrong_account" diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 68f8299e613..31bb6d8d6a7 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from datetime import timedelta +from http import HTTPStatus from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch @@ -14,7 +15,9 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, + GetSetting, HomeAppliance, + SettingKey, ) from aiohomeconnect.model.error import ( EventStreamInterruptedError, @@ -39,6 +42,8 @@ from homeassistant.config_entries import ConfigEntries, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, EVENT_STATE_REPORTED, + STATE_OFF, + STATE_ON, STATE_UNAVAILABLE, Platform, ) @@ -48,11 +53,16 @@ from homeassistant.core import ( HomeAssistant, callback, ) -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import ClientSessionGenerator INITIAL_FETCH_CLIENT_METHODS = [ "get_settings", @@ -312,7 +322,7 @@ async def test_event_listener( assert config_entry.state == ConfigEntryState.LOADED state = hass.states.get(entity_id) - assert state + event_message = EventMessage( appliance.ha_id, event_type, @@ -334,7 +344,8 @@ async def test_event_listener( new_state = hass.states.get(entity_id) assert new_state - assert new_state.state != state.state + if state is not None: + assert new_state.state != state.state # Following, we are gonna check that the listeners are clean up correctly new_entity_id = entity_id + "_new" @@ -608,3 +619,174 @@ async def test_paired_disconnected_devices_not_fetching( client.get_specific_appliance.assert_awaited_once_with(appliance.ha_id) for method in INITIAL_FETCH_CLIENT_METHODS: assert getattr(client, method).call_count == 0 + + +async def test_coordinator_disabling_updates_for_appliance( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test coordinator disables appliance updates on frequent connect/paired events. + + A repair issue should be created when the updates are disabled. + When the user confirms the issue the updates should be enabled again. + """ + appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" + issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state("switch.dishwasher_power", STATE_ON) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(8) + ] + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + get_settings_original_side_effect = client.get_settings.side_effect + + async def get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + if ha_id == appliance_ha_id: + return ArrayOfSettings( + [ + GetSetting( + SettingKey.BSH_COMMON_POWER_STATE, + SettingKey.BSH_COMMON_POWER_STATE.value, + BSH_POWER_OFF, + ) + ] + ) + return cast(ArrayOfSettings, get_settings_original_side_effect(ha_id)) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state("switch.dishwasher_power", STATE_ON) + + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + assert resp.status == HTTPStatus.OK + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) + + +async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_reload( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test that updates are enabled again after unloading the entry. + + The repair issue should also be deleted. + """ + appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" + issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state("switch.dishwasher_power", STATE_ON) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(8) + ] + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + get_settings_original_side_effect = client.get_settings.side_effect + + async def get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + if ha_id == appliance_ha_id: + return ArrayOfSettings( + [ + GetSetting( + SettingKey.BSH_COMMON_POWER_STATE, + SettingKey.BSH_COMMON_POWER_STATE.value, + BSH_POWER_OFF, + ) + ] + ) + return cast(ArrayOfSettings, get_settings_original_side_effect(ha_id)) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 21bb0291e1a..2147d9b170a 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -358,3 +358,20 @@ async def test_bsh_key_transformations() -> None: program = "Dishcare.Dishwasher.Program.Eco50" translation_key = bsh_key_to_translation_key(program) assert RE_TRANSLATION_KEY.match(translation_key) + + +async def test_config_entry_unique_id_migration( + hass: HomeAssistant, + config_entry_v1_2: MockConfigEntry, +) -> None: + """Test that old config entries use the unique id obtained from the JWT subject.""" + config_entry_v1_2.add_to_hass(hass) + + assert config_entry_v1_2.unique_id != "1234567890" + assert config_entry_v1_2.minor_version == 2 + + await hass.config_entries.async_setup(config_entry_v1_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_v1_2.unique_id == "1234567890" + assert config_entry_v1_2.minor_version == 3 diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 4f3f804eb06..6d8c090571e 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -486,6 +486,59 @@ async def test_select_exception_handling( assert getattr(client_with_exception, mock_attr).call_count == 2 +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_programs_updated_on_connect( + appliance: HomeAppliance, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_all_programs_mock = client.get_all_programs + + returned_programs = ( + await get_all_programs_mock.side_effect(appliance.ha_id) + ).programs + assert len(returned_programs) > 1 + + async def get_all_programs_side_effect(ha_id: str): + if ha_id == appliance.ha_id: + return ArrayOfPrograms(returned_programs[:1]) + return await get_all_programs_mock.side_effect(ha_id) + + client.get_all_programs = AsyncMock(side_effect=get_all_programs_side_effect) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_all_programs = get_all_programs_mock + + state = hass.states.get("select.washer_active_program") + assert state + programs = state.attributes[ATTR_OPTIONS] + + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + state = hass.states.get("select.washer_active_program") + assert state + assert state.attributes[ATTR_OPTIONS] != programs + assert len(state.attributes[ATTR_OPTIONS]) > len(programs) + + @pytest.mark.parametrize("appliance", ["Hood"], indirect=True) @pytest.mark.parametrize( ( diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index f0481318a37..d48befcf73f 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -1,6 +1,7 @@ """Tests for home_connect sensor entities.""" from collections.abc import Awaitable, Callable +import logging from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( @@ -153,6 +154,29 @@ async def test_paired_depaired_devices_flow( for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.EVENT, + ArrayOfEvents( + [ + Event( + key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_1_FILL_LEVEL_POOR, + raw_key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_1_FILL_LEVEL_POOR.value, + timestamp=0, + level="", + handling="", + value=BSH_EVENT_PRESENT_STATE_PRESENT, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state("sensor.washer_poor_i_dos_1_fill_level", "present") + @pytest.mark.parametrize( ("appliance", "keys_to_check"), @@ -241,6 +265,28 @@ async def test_sensor_entity_availability( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.EVENT, + ArrayOfEvents( + [ + Event( + key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, + raw_key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY.value, + timestamp=0, + level="", + handling="", + value=BSH_EVENT_PRESENT_STATE_OFF, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + for entity_id in entity_ids: state = hass.states.get(entity_id) assert state @@ -526,143 +572,148 @@ async def test_remaining_prog_time_edge_cases( ( "entity_id", "event_key", - "event_type", - "event_value_update", - "expected", + "value_expected_state", "appliance", ), [ ( "sensor.dishwasher_door", EventKey.BSH_COMMON_STATUS_DOOR_STATE, - EventType.STATUS, - BSH_DOOR_STATE_LOCKED, - "locked", + [ + ( + BSH_DOOR_STATE_LOCKED, + "locked", + ), + ( + BSH_DOOR_STATE_CLOSED, + "closed", + ), + ( + BSH_DOOR_STATE_OPEN, + "open", + ), + ], "Dishwasher", ), - ( - "sensor.dishwasher_door", - EventKey.BSH_COMMON_STATUS_DOOR_STATE, - EventType.STATUS, - BSH_DOOR_STATE_CLOSED, - "closed", - "Dishwasher", - ), - ( - "sensor.dishwasher_door", - EventKey.BSH_COMMON_STATUS_DOOR_STATE, - EventType.STATUS, - BSH_DOOR_STATE_OPEN, - "open", - "Dishwasher", - ), - ( - "sensor.fridgefreezer_freezer_door_alarm", - "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", - EventType.EVENT, - "", - "off", - "FridgeFreezer", - ), - ( - "sensor.fridgefreezer_freezer_door_alarm", - EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_OFF, - "off", - "FridgeFreezer", - ), - ( - "sensor.fridgefreezer_freezer_door_alarm", - EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_PRESENT, - "present", - "FridgeFreezer", - ), - ( - "sensor.fridgefreezer_freezer_door_alarm", - EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_CONFIRMED, - "confirmed", - "FridgeFreezer", - ), - ( - "sensor.coffeemaker_bean_container_empty", - EventType.EVENT, - "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", - "", - "off", - "CoffeeMaker", - ), - ( - "sensor.coffeemaker_bean_container_empty", - EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_OFF, - "off", - "CoffeeMaker", - ), - ( - "sensor.coffeemaker_bean_container_empty", - EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_PRESENT, - "present", - "CoffeeMaker", - ), - ( - "sensor.coffeemaker_bean_container_empty", - EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_CONFIRMED, - "confirmed", - "CoffeeMaker", - ), ], indirect=["appliance"], ) async def test_sensors_states( entity_id: str, event_key: EventKey, - event_type: EventType, - event_value_update: str, + value_expected_state: list[tuple[str, str]], appliance: HomeAppliance, - expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, ) -> None: - """Tests for appliance alarm sensors.""" + """Tests for appliance sensors.""" assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - await client.add_events( - [ - EventMessage( - appliance.ha_id, - event_type, - ArrayOfEvents( - [ - Event( - key=event_key, - raw_key=str(event_key), - timestamp=0, - level="", - handling="", - value=event_value_update, - ) - ], + for value, expected_state in value_expected_state: + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=str(event_key), + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), ), - ), - ] - ) - await hass.async_block_till_done() - assert hass.states.is_state(entity_id, expected) + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, expected_state) + + +@pytest.mark.parametrize( + ( + "entity_id", + "event_key", + "appliance", + ), + [ + ( + "sensor.fridgefreezer_freezer_door_alarm", + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + "FridgeFreezer", + ), + ( + "sensor.coffeemaker_bean_container_empty", + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + "CoffeeMaker", + ), + ], + indirect=["appliance"], +) +async def test_event_sensors_states( + entity_id: str, + event_key: EventKey, + appliance: HomeAppliance, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Tests for appliance event sensors.""" + caplog.set_level(logging.ERROR) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert not hass.states.get(entity_id) + + for value, expected_state in ( + (BSH_EVENT_PRESENT_STATE_OFF, "off"), + (BSH_EVENT_PRESENT_STATE_PRESENT, "present"), + (BSH_EVENT_PRESENT_STATE_CONFIRMED, "confirmed"), + ): + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.EVENT, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=str(event_key), + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, expected_state) + + # Verify that the integration doesn't attempt to add the event sensors more than once + # If that happens, the EntityPlatform logs an error with the entity's unique ID. + assert "exists" not in caplog.text + assert entity_id not in caplog.text + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.unique_id not in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/homeassistant/triggers/test_time_pattern.py b/tests/components/homeassistant/triggers/test_time_pattern.py index ffce8cd476b..2e7fa9dae08 100644 --- a/tests/components/homeassistant/triggers/test_time_pattern.py +++ b/tests/components/homeassistant/triggers/test_time_pattern.py @@ -365,6 +365,7 @@ async def test_invalid_schemas() -> None: {"platform": "time_pattern", "minutes": "/"}, {"platform": "time_pattern", "minutes": "*/5"}, {"platform": "time_pattern", "minutes": "/90"}, + {"platform": "time_pattern", "hours": "/0", "minutes": 10}, {"platform": "time_pattern", "hours": 12, "minutes": 0, "seconds": 100}, ) diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 3081c44c681..2d5067bea3e 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -584,6 +584,7 @@ async def test_config_flow_zigbee_not_hassio(hass: HomeAssistant) -> None: assert zha_flow["step_id"] == "confirm" +@pytest.mark.usefixtures("addon_store_info") async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: """Test the options flow, migrating Zigbee to Thread.""" config_entry = MockConfigEntry( @@ -675,6 +676,7 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: assert config_entry.data["firmware"] == "spinel" +@pytest.mark.usefixtures("addon_store_info") async def test_options_flow_thread_to_zigbee(hass: HomeAssistant) -> None: """Test the options flow, migrating Thread to Zigbee.""" config_entry = MockConfigEntry( diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index fb38704ae61..38c2696a62a 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -45,6 +45,7 @@ async def fixture_mock_supervisor_client(supervisor_client: AsyncMock): STEP_PICK_FIRMWARE_THREAD, ], ) +@pytest.mark.usefixtures("addon_store_info") async def test_config_flow_cannot_probe_firmware( next_step: str, hass: HomeAssistant ) -> None: @@ -660,6 +661,7 @@ async def test_options_flow_zigbee_to_thread_zha_configured( "ignore_translations_for_mock_domains", ["test_firmware_domain"], ) +@pytest.mark.usefixtures("addon_store_info") async def test_options_flow_thread_to_zigbee_otbr_configured( hass: HomeAssistant, ) -> None: diff --git a/tests/components/homee/fixtures/numbers.json b/tests/components/homee/fixtures/numbers.json index c8773a89568..fd00ca4b5bd 100644 --- a/tests/components/homee/fixtures/numbers.json +++ b/tests/components/homee/fixtures/numbers.json @@ -19,7 +19,7 @@ "security": 0, "attributes": [ { - "id": 2, + "id": 1, "node_id": 1, "instance": 0, "minimum": 0, @@ -40,7 +40,7 @@ "name": "" }, { - "id": 3, + "id": 2, "node_id": 1, "instance": 0, "minimum": -75, @@ -61,7 +61,7 @@ "name": "" }, { - "id": 4, + "id": 3, "node_id": 1, "instance": 0, "minimum": 4, @@ -82,7 +82,7 @@ "name": "" }, { - "id": 5, + "id": 4, "node_id": 1, "instance": 0, "minimum": 0, @@ -103,7 +103,7 @@ "name": "" }, { - "id": 6, + "id": 5, "node_id": 1, "instance": 0, "minimum": 1, @@ -124,7 +124,7 @@ "name": "" }, { - "id": 7, + "id": 6, "node_id": 1, "instance": 0, "minimum": 0, @@ -145,7 +145,7 @@ "name": "" }, { - "id": 8, + "id": 7, "node_id": 1, "instance": 0, "minimum": 5, @@ -166,7 +166,7 @@ "name": "" }, { - "id": 9, + "id": 8, "node_id": 1, "instance": 0, "minimum": 0, @@ -187,7 +187,7 @@ "name": "" }, { - "id": 10, + "id": 9, "node_id": 1, "instance": 0, "minimum": -127, @@ -208,7 +208,7 @@ "name": "" }, { - "id": 11, + "id": 10, "node_id": 1, "instance": 0, "minimum": -127, @@ -229,7 +229,7 @@ "name": "" }, { - "id": 12, + "id": 11, "node_id": 1, "instance": 0, "minimum": 1, @@ -250,7 +250,7 @@ "name": "" }, { - "id": 13, + "id": 12, "node_id": 1, "instance": 0, "minimum": -5, @@ -271,7 +271,7 @@ "name": "" }, { - "id": 14, + "id": 13, "node_id": 1, "instance": 0, "minimum": 4, @@ -292,7 +292,7 @@ "name": "" }, { - "id": 15, + "id": 14, "node_id": 1, "instance": 0, "minimum": 30, @@ -313,7 +313,7 @@ "name": "" }, { - "id": 16, + "id": 15, "node_id": 1, "instance": 0, "minimum": 0, @@ -332,6 +332,27 @@ "based_on": 1, "data": "fixed_value", "name": "" + }, + { + "id": 16, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 9, + "current_value": 2.0, + "target_value": 2.0, + "last_value": 2.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 338, + "state": 1, + "last_changed": 1684668852, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" } ] } diff --git a/tests/components/homee/fixtures/thermostat_only_targettemp.json b/tests/components/homee/fixtures/thermostat_only_targettemp.json new file mode 100644 index 00000000000..4bdbaa0df78 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_only_targettemp.json @@ -0,0 +1,52 @@ +{ + "id": 1, + "name": "Test Thermostat 1", + "profile": 3003, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 12, + "maximum": 28, + "current_value": 20.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + } + ] +} diff --git a/tests/components/homee/fixtures/thermostat_with_currenttemp.json b/tests/components/homee/fixtures/thermostat_with_currenttemp.json new file mode 100644 index 00000000000..9685034f178 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_with_currenttemp.json @@ -0,0 +1,77 @@ +{ + "id": 2, + "name": "Test Thermostat 2", + "profile": 3003, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 2, + "instance": 0, + "minimum": 15, + "maximum": 30, + "current_value": 22.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 2, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 19.55, + "target_value": 19.55, + "last_value": 21.07, + "unit": "°C", + "step_value": 0.1, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1713695528, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "observed_by": [240], + "history": { "day": 1, "week": 26, "month": 6 } + } + } + ] +} diff --git a/tests/components/homee/fixtures/thermostat_with_heating_mode.json b/tests/components/homee/fixtures/thermostat_with_heating_mode.json new file mode 100644 index 00000000000..fe06e9ef4a5 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_with_heating_mode.json @@ -0,0 +1,127 @@ +{ + "id": 3, + "name": "Test Thermostat 3", + "profile": 3006, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 3, + "instance": 0, + "minimum": 14, + "maximum": 25, + "current_value": 24.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 3, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 19.55, + "target_value": 19.55, + "last_value": 21.07, + "unit": "°C", + "step_value": 0.1, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1713695528, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "observed_by": [240], + "history": { "day": 1, "week": 26, "month": 6 } + } + }, + { + "id": 3, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 258, + "state": 1, + "last_changed": 1711796635, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 4, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 70.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 18, + "state": 1, + "last_changed": 1711796633, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 1, + "week": 26, + "month": 6 + } + } + } + ] +} diff --git a/tests/components/homee/fixtures/thermostat_with_preset.json b/tests/components/homee/fixtures/thermostat_with_preset.json new file mode 100644 index 00000000000..63491d45be2 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_with_preset.json @@ -0,0 +1,98 @@ +{ + "id": 4, + "name": "Test Thermostat 4", + "profile": 3033, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 4, + "instance": 0, + "minimum": 10, + "maximum": 32, + "current_value": 12.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.5, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 4, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 19.55, + "target_value": 19.55, + "last_value": 21.07, + "unit": "°C", + "step_value": 0.1, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1713695528, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "observed_by": [240], + "history": { "day": 1, "week": 26, "month": 6 } + } + }, + { + "id": 3, + "node_id": 4, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 258, + "state": 1, + "last_changed": 1711796635, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/snapshots/test_climate.ambr b/tests/components/homee/snapshots/test_climate.ambr new file mode 100644 index 00000000000..b79538ddcf0 --- /dev/null +++ b/tests/components/homee/snapshots/test_climate.ambr @@ -0,0 +1,274 @@ +# serializer version: 1 +# name: test_climate_snapshot[climate.test_thermostat_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 28, + 'min_temp': 12, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_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': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Test Thermostat 1', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 28, + 'min_temp': 12, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30, + 'min_temp': 15, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_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': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-2-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.6, + 'friendly_name': 'Test Thermostat 2', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30, + 'min_temp': 15, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 25, + 'min_temp': 14, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_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': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.6, + 'friendly_name': 'Test Thermostat 3', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 25, + 'min_temp': 14, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 24.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 10, + 'preset_modes': list([ + 'none', + 'eco', + 'boost', + 'manual', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_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': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-4-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.6, + 'friendly_name': 'Test Thermostat 4', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 10, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'eco', + 'boost', + 'manual', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 12.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/homee/snapshots/test_number.ambr b/tests/components/homee/snapshots/test_number.ambr index 04b1aefab00..1fa2e0ef697 100644 --- a/tests/components/homee/snapshots/test_number.ambr +++ b/tests/components/homee/snapshots/test_number.ambr @@ -34,7 +34,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'down_time', - 'unique_id': '00055511EECC-1-4', + 'unique_id': '00055511EECC-1-3', 'unit_of_measurement': , }) # --- @@ -54,7 +54,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '57', + 'state': '57.0', }) # --- # name: test_number_snapshot[number.test_number_down_position-entry] @@ -92,7 +92,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'down_position', - 'unique_id': '00055511EECC-1-2', + 'unique_id': '00055511EECC-1-1', 'unit_of_measurement': '%', }) # --- @@ -111,7 +111,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_number_snapshot[number.test_number_down_slat_position-entry] @@ -149,7 +149,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'down_slat_position', - 'unique_id': '00055511EECC-1-3', + 'unique_id': '00055511EECC-1-2', 'unit_of_measurement': '°', }) # --- @@ -168,7 +168,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '38', + 'state': '38.0', }) # --- # name: test_number_snapshot[number.test_number_end_position-entry] @@ -206,7 +206,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'endposition_configuration', - 'unique_id': '00055511EECC-1-5', + 'unique_id': '00055511EECC-1-4', 'unit_of_measurement': None, }) # --- @@ -224,7 +224,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '129', + 'state': '129.0', }) # --- # name: test_number_snapshot[number.test_number_maximum_slat_angle-entry] @@ -262,7 +262,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'slat_max_angle', - 'unique_id': '00055511EECC-1-10', + 'unique_id': '00055511EECC-1-9', 'unit_of_measurement': '°', }) # --- @@ -281,7 +281,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '75', + 'state': '75.0', }) # --- # name: test_number_snapshot[number.test_number_minimum_slat_angle-entry] @@ -319,7 +319,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'slat_min_angle', - 'unique_id': '00055511EECC-1-11', + 'unique_id': '00055511EECC-1-10', 'unit_of_measurement': '°', }) # --- @@ -338,7 +338,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-75', + 'state': '-75.0', }) # --- # name: test_number_snapshot[number.test_number_motion_alarm_delay-entry] @@ -376,7 +376,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'motion_alarm_cancelation_delay', - 'unique_id': '00055511EECC-1-6', + 'unique_id': '00055511EECC-1-5', 'unit_of_measurement': , }) # --- @@ -434,7 +434,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'polling_interval', - 'unique_id': '00055511EECC-1-8', + 'unique_id': '00055511EECC-1-7', 'unit_of_measurement': , }) # --- @@ -454,7 +454,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30', + 'state': '30.0', }) # --- # name: test_number_snapshot[number.test_number_slat_steps-entry] @@ -492,7 +492,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'slat_steps', - 'unique_id': '00055511EECC-1-12', + 'unique_id': '00055511EECC-1-11', 'unit_of_measurement': None, }) # --- @@ -510,7 +510,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '6', + 'state': '6.0', }) # --- # name: test_number_snapshot[number.test_number_slat_turn_duration-entry] @@ -548,7 +548,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'shutter_slat_time', - 'unique_id': '00055511EECC-1-9', + 'unique_id': '00055511EECC-1-8', 'unit_of_measurement': , }) # --- @@ -568,7 +568,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '1.6', }) # --- # name: test_number_snapshot[number.test_number_temperature_offset-entry] @@ -606,7 +606,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'temperature_offset', - 'unique_id': '00055511EECC-1-13', + 'unique_id': '00055511EECC-1-12', 'unit_of_measurement': , }) # --- @@ -628,6 +628,64 @@ 'state': 'unavailable', }) # --- +# name: test_number_snapshot[number.test_number_threshold_for_wind_trigger-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 22.5, + 'min': 0, + 'mode': , + 'step': 2.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_threshold_for_wind_trigger', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Threshold for wind trigger', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_monitoring_state', + 'unique_id': '00055511EECC-1-16', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_threshold_for_wind_trigger-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'Test Number Threshold for wind trigger', + 'max': 22.5, + 'min': 0, + 'mode': , + 'step': 2.5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_threshold_for_wind_trigger', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- # name: test_number_snapshot[number.test_number_up_movement_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -663,7 +721,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'up_time', - 'unique_id': '00055511EECC-1-14', + 'unique_id': '00055511EECC-1-13', 'unit_of_measurement': , }) # --- @@ -683,7 +741,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '57', + 'state': '57.0', }) # --- # name: test_number_snapshot[number.test_number_wake_up_interval-entry] @@ -721,7 +779,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wake_up_interval', - 'unique_id': '00055511EECC-1-15', + 'unique_id': '00055511EECC-1-14', 'unit_of_measurement': , }) # --- @@ -741,7 +799,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '600', + 'state': '600.0', }) # --- # name: test_number_snapshot[number.test_number_window_open_sensibility-entry] @@ -779,7 +837,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'open_window_detection_sensibility', - 'unique_id': '00055511EECC-1-7', + 'unique_id': '00055511EECC-1-6', 'unit_of_measurement': None, }) # --- @@ -797,6 +855,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3', + 'state': '3.0', }) # --- diff --git a/tests/components/homee/test_climate.py b/tests/components/homee/test_climate.py new file mode 100644 index 00000000000..bb5ad98c7d2 --- /dev/null +++ b/tests/components/homee/test_climate.py @@ -0,0 +1,270 @@ +"""Test Homee climate entities.""" + +from unittest.mock import MagicMock, patch + +from pyHomee.const import AttributeType +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + PRESET_BOOST, + PRESET_ECO, + PRESET_NONE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.components.homee.const import PRESET_MANUAL +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def setup_mock_climate( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + file: str, +) -> None: + """Setups a climate node for the tests.""" + mock_homee.nodes = [build_mock_node(file)] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + +@pytest.mark.parametrize( + ("file", "entity_id", "features", "hvac_modes"), + [ + ( + "thermostat_only_targettemp.json", + "climate.test_thermostat_1", + ClimateEntityFeature.TARGET_TEMPERATURE, + [HVACMode.HEAT], + ), + ( + "thermostat_with_currenttemp.json", + "climate.test_thermostat_2", + ClimateEntityFeature.TARGET_TEMPERATURE, + [HVACMode.HEAT], + ), + ( + "thermostat_with_heating_mode.json", + "climate.test_thermostat_3", + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF, + [HVACMode.HEAT, HVACMode.OFF], + ), + ( + "thermostat_with_preset.json", + "climate.test_thermostat_4", + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.PRESET_MODE, + [HVACMode.HEAT, HVACMode.OFF], + ), + ], +) +async def test_climate_features( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + file: str, + entity_id: str, + features: ClimateEntityFeature, + hvac_modes: list[HVACMode], +) -> None: + """Test available features of climate entities.""" + await setup_mock_climate(hass, mock_config_entry, mock_homee, file) + + attributes = hass.states.get(entity_id).attributes + assert attributes["supported_features"] == features + assert attributes[ATTR_HVAC_MODES] == hvac_modes + + +async def test_climate_preset_modes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, +) -> None: + """Test available preset modes of climate entities.""" + await setup_mock_climate( + hass, mock_config_entry, mock_homee, "thermostat_with_preset.json" + ) + + attributes = hass.states.get("climate.test_thermostat_4").attributes + assert attributes[ATTR_PRESET_MODES] == [ + PRESET_NONE, + PRESET_ECO, + PRESET_BOOST, + PRESET_MANUAL, + ] + + +@pytest.mark.parametrize( + ("attribute_type", "value", "expected"), + [ + (AttributeType.HEATING_MODE, 0.0, HVACAction.OFF), + (AttributeType.CURRENT_VALVE_POSITION, 0.0, HVACAction.IDLE), + (AttributeType.TEMPERATURE, 25.0, HVACAction.IDLE), + (AttributeType.TEMPERATURE, 18.0, HVACAction.HEATING), + ], +) +async def test_hvac_action( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + attribute_type: AttributeType, + value: float, + expected: HVACAction, +) -> None: + """Test hvac action of climate entities.""" + mock_homee.nodes = [build_mock_node("thermostat_with_heating_mode.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + node = mock_homee.nodes[0] + # set target temperature to 24.0 + node.attributes[0].current_value = 24.0 + attribute = node.get_attribute_by_type(attribute_type) + attribute.current_value = value + await setup_integration(hass, mock_config_entry) + + attributes = hass.states.get("climate.test_thermostat_3").attributes + assert attributes[ATTR_HVAC_ACTION] == expected + + +@pytest.mark.parametrize( + ("preset_mode_int", "expected"), + [ + (0, PRESET_NONE), + (1, PRESET_NONE), + (2, PRESET_ECO), + (3, PRESET_BOOST), + (4, PRESET_MANUAL), + ], +) +async def test_current_preset_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + preset_mode_int: int, + expected: str, +) -> None: + """Test current preset mode of climate entities.""" + mock_homee.nodes = [build_mock_node("thermostat_with_preset.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + node = mock_homee.nodes[0] + node.attributes[2].current_value = preset_mode_int + await setup_integration(hass, mock_config_entry) + + attributes = hass.states.get("climate.test_thermostat_4").attributes + assert attributes[ATTR_PRESET_MODE] == expected + + +@pytest.mark.parametrize( + ("service", "service_data", "expected"), + [ + ( + SERVICE_TURN_ON, + {}, + (4, 3, 1), + ), + ( + SERVICE_TURN_OFF, + {}, + (4, 3, 0), + ), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.HEAT}, + (4, 3, 1), + ), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.OFF}, + (4, 3, 0), + ), + ( + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: 20}, + (4, 1, 20), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_NONE}, + (4, 3, 1), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_ECO}, + (4, 3, 2), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_BOOST}, + (4, 3, 3), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_MANUAL}, + (4, 3, 4), + ), + ], +) +async def test_climate_services( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + service: str, + service_data: dict, + expected: tuple[int, int, int], +) -> None: + """Test available services of climate entities.""" + await setup_mock_climate( + hass, mock_config_entry, mock_homee, "thermostat_with_preset.json" + ) + + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: "climate.test_thermostat_4", **service_data}, + blocking=True, + ) + + mock_homee.set_value.assert_called_once_with(*expected) + + +async def test_climate_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test snapshot of climates.""" + mock_homee.nodes = [ + build_mock_node("thermostat_only_targettemp.json"), + build_mock_node("thermostat_with_currenttemp.json"), + build_mock_node("thermostat_with_heating_mode.json"), + build_mock_node("thermostat_with_preset.json"), + ] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_number.py b/tests/components/homee/test_number.py index 73ca707c2d5..2825152241a 100644 --- a/tests/components/homee/test_number.py +++ b/tests/components/homee/test_number.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( @@ -18,24 +19,62 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform -async def test_set_value( - hass: HomeAssistant, - mock_homee: MagicMock, - mock_config_entry: MockConfigEntry, +async def setup_numbers( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry ) -> None: - """Test set_value service.""" + """Set up the number platform.""" mock_homee.nodes = [build_mock_node("numbers.json")] mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] await setup_integration(hass, mock_config_entry) + +@pytest.mark.parametrize( + ("entity_id", "expected"), + [ + ("number.test_number_down_position", 100.0), + ("number.test_number_threshold_for_wind_trigger", 5.0), + ], +) +async def test_value_fn( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + expected: float, +) -> None: + """Test the value_fn of the number entity.""" + await setup_numbers(hass, mock_homee, mock_config_entry) + + assert hass.states.get(entity_id).state == str(expected) + + +@pytest.mark.parametrize( + ("entity_id", "attribute_index", "value", "expected"), + [ + ("number.test_number_down_position", 0, 90, 90), + ("number.test_number_threshold_for_wind_trigger", 15, 7.5, 3), + ], +) +async def test_set_value( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + attribute_index: int, + value: float, + expected: float, +) -> None: + """Test set_value service.""" + await setup_numbers(hass, mock_homee, mock_config_entry) + await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: "number.test_number_down_position", ATTR_VALUE: 90}, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, blocking=True, ) - number = mock_homee.nodes[0].attributes[0] - mock_homee.set_value.assert_called_once_with(number.node_id, number.id, 90) + number = mock_homee.nodes[0].attributes[attribute_index] + mock_homee.set_value.assert_called_once_with(number.node_id, number.id, expected) async def test_set_value_not_editable( @@ -44,9 +83,7 @@ async def test_set_value_not_editable( mock_config_entry: MockConfigEntry, ) -> None: """Test set_value if attribute is not editable.""" - mock_homee.nodes = [build_mock_node("numbers.json")] - mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - await setup_integration(hass, mock_config_entry) + await setup_numbers(hass, mock_homee, mock_config_entry) await hass.services.async_call( NUMBER_DOMAIN, @@ -66,9 +103,7 @@ async def test_number_snapshot( snapshot: SnapshotAssertion, ) -> None: """Test the multisensor snapshot.""" - mock_homee.nodes = [build_mock_node("numbers.json")] - mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] with patch("homeassistant.components.homee.PLATFORMS", [Platform.NUMBER]): - await setup_integration(hass, mock_config_entry) + await setup_numbers(hass, mock_homee, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homekit/test_diagnostics.py b/tests/components/homekit/test_diagnostics.py index ce3c954c447..912c5953176 100644 --- a/tests/components/homekit/test_diagnostics.py +++ b/tests/components/homekit/test_diagnostics.py @@ -453,7 +453,7 @@ async def test_config_entry_with_trigger_accessory( "iid": 6, "perms": ["pr"], "type": "30", - "value": ANY, + "value": device_id, }, { "format": "string", @@ -484,8 +484,15 @@ async def test_config_entry_with_trigger_accessory( "value": "Ceiling Lights Changed States", }, { - "format": "uint8", + "format": "string", "iid": 11, + "perms": ["pr", "pw", "ev"], + "type": "E3", + "value": "Ceiling Lights Changed States", + }, + { + "format": "uint8", + "iid": 12, "maxValue": 255, "minStep": 1, "minValue": 1, @@ -495,28 +502,28 @@ async def test_config_entry_with_trigger_accessory( }, ], "iid": 8, - "linked": [12], + "linked": [13], "type": "89", }, { "characteristics": [ { "format": "uint8", - "iid": 13, + "iid": 14, "perms": ["pr"], "type": "CD", "valid-values": [0, 1], "value": 1, } ], - "iid": 12, + "iid": 13, "type": "CC", }, { "characteristics": [ { "format": "uint8", - "iid": 15, + "iid": 16, "perms": ["pr", "ev"], "type": "73", "valid-values": [0], @@ -524,14 +531,21 @@ async def test_config_entry_with_trigger_accessory( }, { "format": "string", - "iid": 16, + "iid": 17, "perms": ["pr"], "type": "23", "value": "Ceiling Lights Turned Off", }, + { + "format": "string", + "iid": 18, + "perms": ["pr", "pw", "ev"], + "type": "E3", + "value": "Ceiling Lights Turned Off", + }, { "format": "uint8", - "iid": 17, + "iid": 19, "maxValue": 255, "minStep": 1, "minValue": 1, @@ -540,29 +554,29 @@ async def test_config_entry_with_trigger_accessory( "value": 2, }, ], - "iid": 14, - "linked": [18], + "iid": 15, + "linked": [20], "type": "89", }, { "characteristics": [ { "format": "uint8", - "iid": 19, + "iid": 21, "perms": ["pr"], "type": "CD", "valid-values": [0, 1], "value": 1, } ], - "iid": 18, + "iid": 20, "type": "CC", }, { "characteristics": [ { "format": "uint8", - "iid": 21, + "iid": 23, "perms": ["pr", "ev"], "type": "73", "valid-values": [0], @@ -570,14 +584,21 @@ async def test_config_entry_with_trigger_accessory( }, { "format": "string", - "iid": 22, + "iid": 24, "perms": ["pr"], "type": "23", "value": "Ceiling Lights Turned On", }, + { + "format": "string", + "iid": 25, + "perms": ["pr", "pw", "ev"], + "type": "E3", + "value": "Ceiling Lights Turned On", + }, { "format": "uint8", - "iid": 23, + "iid": 26, "maxValue": 255, "minStep": 1, "minValue": 1, @@ -586,22 +607,22 @@ async def test_config_entry_with_trigger_accessory( "value": 3, }, ], - "iid": 20, - "linked": [24], + "iid": 22, + "linked": [27], "type": "89", }, { "characteristics": [ { "format": "uint8", - "iid": 25, + "iid": 28, "perms": ["pr"], "type": "CD", "valid-values": [0, 1], "value": 1, } ], - "iid": 24, + "iid": 27, "type": "CC", }, ], @@ -626,6 +647,7 @@ async def test_config_entry_with_trigger_accessory( "pairing_id": ANY, "status": 1, } + with ( patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch("homeassistant.components.homekit.HomeKit.async_stop"), diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index c4b1cbe98d8..de5cda71513 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -6,11 +6,13 @@ import pytest from homeassistant.components.climate import ClimateEntityFeature from homeassistant.components.cover import CoverEntityFeature +from homeassistant.components.homekit import TYPE_AIR_PURIFIER from homeassistant.components.homekit.accessories import TYPES, get_accessory from homeassistant.components.homekit.const import ( ATTR_INTEGRATION, CONF_FEATURE_LIST, FEATURE_ON_OFF, + TYPE_FAN, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -51,6 +53,12 @@ def test_not_supported(caplog: pytest.LogCaptureFixture) -> None: assert "invalid aid" in caplog.records[0].msg +def test_not_supported_sensor(caplog: pytest.LogCaptureFixture) -> None: + """Test if none is returned if entity isn't supported.""" + assert get_accessory(None, None, State("sensor.xyz", "on"), 2, {}) is None + assert "Unsupported sensor type (device_class=None)" in caplog.text + + def test_not_supported_media_player() -> None: """Test if mode isn't supported and if no supported modes.""" # selected mode for entity not supported @@ -350,6 +358,23 @@ def test_type_switches(type_name, entity_id, state, attrs, config) -> None: assert mock_type.called +@pytest.mark.parametrize( + ("type_name", "entity_id", "state", "attrs", "config"), + [ + ("Fan", "fan.test", "on", {}, {}), + ("Fan", "fan.test", "on", {}, {CONF_TYPE: TYPE_FAN}), + ("AirPurifier", "fan.test", "on", {}, {CONF_TYPE: TYPE_AIR_PURIFIER}), + ], +) +def test_type_fans(type_name, entity_id, state, attrs, config) -> None: + """Test if switch types are associated correctly.""" + mock_type = Mock() + with patch.dict(TYPES, {type_name: mock_type}): + entity_state = State(entity_id, state, attrs) + get_accessory(None, None, entity_state, 2, config) + assert mock_type.called + + @pytest.mark.parametrize( ("type_name", "entity_id", "state", "attrs"), [ diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 0829c96ce1d..f59c5d2778b 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -21,6 +21,7 @@ from homeassistant.components.homekit import ( STATUS_RUNNING, STATUS_STOPPED, STATUS_WAIT, + TYPE_AIR_PURIFIER, HomeKit, ) from homeassistant.components.homekit.accessories import HomeBridge @@ -51,6 +52,7 @@ from homeassistant.const import ( ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_STARTED, @@ -58,6 +60,7 @@ from homeassistant.const import ( SERVICE_RELOAD, STATE_ON, EntityCategory, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError @@ -2162,6 +2165,109 @@ async def test_homekit_finds_linked_humidity_sensors( ) +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_finds_linked_air_purifier_sensors( + hass: HomeAssistant, + hk_driver, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HomeKit start method.""" + entry = await async_init_integration(hass) + + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + homekit.driver = hk_driver + homekit.bridge = HomeBridge(hass, hk_driver, "mock_bridge") + + config_entry = MockConfigEntry(domain="air_purifier", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + sw_version="0.16.1", + model="Smart Air Purifier", + manufacturer="Home Assistant", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + humidity_sensor = entity_registry.async_get_or_create( + "sensor", + "air_purifier", + "humidity_sensor", + device_id=device_entry.id, + original_device_class=SensorDeviceClass.HUMIDITY, + ) + pm25_sensor = entity_registry.async_get_or_create( + "sensor", + "air_purifier", + "pm25_sensor", + device_id=device_entry.id, + original_device_class=SensorDeviceClass.PM25, + ) + temperature_sensor = entity_registry.async_get_or_create( + "sensor", + "air_purifier", + "temperature_sensor", + device_id=device_entry.id, + original_device_class=SensorDeviceClass.TEMPERATURE, + ) + air_purifier = entity_registry.async_get_or_create( + "fan", "air_purifier", "demo", device_id=device_entry.id + ) + + hass.states.async_set( + humidity_sensor.entity_id, + "42", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + }, + ) + hass.states.async_set( + pm25_sensor.entity_id, + 8, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + ) + hass.states.async_set( + temperature_sensor.entity_id, + 22, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + hass.states.async_set(air_purifier.entity_id, STATE_ON) + + with ( + patch.object(homekit.bridge, "add_accessory"), + patch(f"{PATH_HOMEKIT}.async_show_setup_message"), + patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, + patch("pyhap.accessory_driver.AccessoryDriver.async_start"), + ): + await homekit.async_start() + await hass.async_block_till_done() + + mock_get_acc.assert_called_with( + hass, + ANY, + ANY, + ANY, + { + "manufacturer": "Home Assistant", + "model": "Smart Air Purifier", + "platform": "air_purifier", + "sw_version": "0.16.1", + "type": TYPE_AIR_PURIFIER, + "linked_humidity_sensor": "sensor.air_purifier_humidity_sensor", + "linked_pm25_sensor": "sensor.air_purifier_pm25_sensor", + "linked_temperature_sensor": "sensor.air_purifier_temperature_sensor", + }, + ) + + @pytest.mark.usefixtures("mock_async_zeroconf") async def test_reload(hass: HomeAssistant) -> None: """Test we can reload from yaml.""" diff --git a/tests/components/homekit/test_type_air_purifiers.py b/tests/components/homekit/test_type_air_purifiers.py new file mode 100644 index 00000000000..90b0e0047de --- /dev/null +++ b/tests/components/homekit/test_type_air_purifiers.py @@ -0,0 +1,702 @@ +"""Test different accessory types: Air Purifiers.""" + +from unittest.mock import MagicMock + +from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE +import pytest + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + DOMAIN as FAN_DOMAIN, + FanEntityFeature, +) +from homeassistant.components.homekit import ( + CONF_LINKED_HUMIDITY_SENSOR, + CONF_LINKED_PM25_SENSOR, + CONF_LINKED_TEMPERATURE_SENSOR, +) +from homeassistant.components.homekit.const import ( + CONF_LINKED_FILTER_CHANGE_INDICATION, + CONF_LINKED_FILTER_LIFE_LEVEL, + THRESHOLD_FILTER_CHANGE_NEEDED, +) +from homeassistant.components.homekit.type_air_purifiers import ( + FILTER_CHANGE_FILTER, + FILTER_OK, + TARGET_STATE_AUTO, + TARGET_STATE_MANUAL, + AirPurifier, +) +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import Event, HomeAssistant + +from tests.common import async_mock_service + + +@pytest.mark.parametrize( + ("auto_preset", "preset_modes"), + [ + ("auto", ["sleep", "smart", "auto"]), + ("Auto", ["sleep", "smart", "Auto"]), + ], +) +async def test_fan_auto_manual( + hass: HomeAssistant, + hk_driver, + events: list[Event], + auto_preset: str, + preset_modes: list[str], +) -> None: + """Test switching between Auto and Manual.""" + entity_id = "fan.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PRESET_MODE: auto_preset, + ATTR_PRESET_MODES: preset_modes, + }, + ) + await hass.async_block_till_done() + acc = AirPurifier(hass, hk_driver, "Air Purifier", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.preset_mode_chars["smart"].value == 0 + assert acc.preset_mode_chars["sleep"].value == 0 + assert acc.auto_preset is not None + + # Auto presets are handled as the target air purifier state, so + # not supposed to be exposed as a separate switch + switches = set() + for service in acc.services: + if service.display_name == "Switch": + switches.add(service.unique_id) + + assert len(switches) == len(preset_modes) - 1 + for preset in preset_modes: + if preset != auto_preset: + assert preset in switches + else: + # Auto preset should not be in switches + assert preset not in switches + + acc.run() + await hass.async_block_till_done() + + assert acc.char_target_air_purifier_state.value == TARGET_STATE_AUTO + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PRESET_MODE: "smart", + ATTR_PRESET_MODES: preset_modes, + }, + ) + await hass.async_block_till_done() + + assert acc.preset_mode_chars["smart"].value == 1 + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + + # Set from HomeKit + call_set_preset_mode = async_mock_service(hass, FAN_DOMAIN, "set_preset_mode") + call_set_percentage = async_mock_service(hass, FAN_DOMAIN, "set_percentage") + char_auto_iid = acc.char_target_air_purifier_state.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_auto_iid, + HAP_REPR_VALUE: 1, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + + assert acc.char_target_air_purifier_state.value == TARGET_STATE_AUTO + assert len(call_set_preset_mode) == 1 + assert call_set_preset_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_preset_mode[0].data[ATTR_PRESET_MODE] == auto_preset + assert len(events) == 1 + assert events[-1].data["service"] == "set_preset_mode" + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_auto_iid, + HAP_REPR_VALUE: 0, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + assert len(call_set_percentage) == 1 + assert call_set_percentage[0].data[ATTR_ENTITY_ID] == entity_id + assert events[-1].data["service"] == "set_percentage" + assert len(events) == 2 + + +async def test_presets_no_auto( + hass: HomeAssistant, + hk_driver, + events: list[Event], +) -> None: + """Test preset without an auto mode.""" + entity_id = "fan.demo" + + preset_modes = ["sleep", "smart"] + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PRESET_MODE: "smart", + ATTR_PRESET_MODES: preset_modes, + }, + ) + await hass.async_block_till_done() + acc = AirPurifier(hass, hk_driver, "Air Purifier", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.preset_mode_chars["smart"].value == 1 + assert acc.preset_mode_chars["sleep"].value == 0 + assert acc.auto_preset is None + + # Auto presets are handled as the target air purifier state, so + # not supposed to be exposed as a separate switch + switches = set() + for service in acc.services: + if service.display_name == "Switch": + switches.add(service.unique_id) + + assert len(switches) == len(preset_modes) + for preset in preset_modes: + assert preset in switches + + acc.run() + await hass.async_block_till_done() + + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PRESET_MODE: "sleep", + ATTR_PRESET_MODES: preset_modes, + }, + ) + await hass.async_block_till_done() + + assert acc.preset_mode_chars["smart"].value == 0 + assert acc.preset_mode_chars["sleep"].value == 1 + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + + +async def test_air_purifier_single_preset_mode( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test air purifier with a single preset mode.""" + entity_id = "fan.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PERCENTAGE: 42, + ATTR_PRESET_MODE: "auto", + ATTR_PRESET_MODES: ["auto"], + }, + ) + await hass.async_block_till_done() + acc = AirPurifier(hass, hk_driver, "Air Purifier", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_target_air_purifier_state.value == TARGET_STATE_AUTO + + acc.run() + await hass.async_block_till_done() + + # Set from HomeKit + call_set_preset_mode = async_mock_service(hass, FAN_DOMAIN, "set_preset_mode") + call_set_percentage = async_mock_service(hass, FAN_DOMAIN, "set_percentage") + + char_target_air_purifier_state_iid = acc.char_target_air_purifier_state.to_HAP()[ + HAP_REPR_IID + ] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_air_purifier_state_iid, + HAP_REPR_VALUE: TARGET_STATE_MANUAL, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert call_set_percentage[0] + assert call_set_percentage[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_percentage[0].data[ATTR_PERCENTAGE] == 42 + assert len(events) == 1 + assert events[-1].data["service"] == "set_percentage" + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_air_purifier_state_iid, + HAP_REPR_VALUE: 1, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert call_set_preset_mode[0] + assert call_set_preset_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_preset_mode[0].data[ATTR_PRESET_MODE] == "auto" + assert events[-1].data["service"] == "set_preset_mode" + assert len(events) == 2 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PERCENTAGE: 42, + ATTR_PRESET_MODE: None, + ATTR_PRESET_MODES: ["auto"], + }, + ) + await hass.async_block_till_done() + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + + +async def test_expose_linked_sensors( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test that linked sensors are exposed.""" + entity_id = "fan.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + }, + ) + + humidity_entity_id = "sensor.demo_humidity" + hass.states.async_set( + humidity_entity_id, + 50, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + }, + ) + + pm25_entity_id = "sensor.demo_pm25" + hass.states.async_set( + pm25_entity_id, + 10, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + }, + ) + + temperature_entity_id = "sensor.demo_temperature" + hass.states.async_set( + temperature_entity_id, + 25, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + + await hass.async_block_till_done() + acc = AirPurifier( + hass, + hk_driver, + "Air Purifier", + entity_id, + 1, + { + CONF_LINKED_TEMPERATURE_SENSOR: temperature_entity_id, + CONF_LINKED_PM25_SENSOR: pm25_entity_id, + CONF_LINKED_HUMIDITY_SENSOR: humidity_entity_id, + }, + ) + hk_driver.add_accessory(acc) + + assert acc.linked_humidity_sensor is not None + assert acc.char_current_humidity is not None + assert acc.linked_pm25_sensor is not None + assert acc.char_pm25_density is not None + assert acc.char_air_quality is not None + assert acc.linked_temperature_sensor is not None + assert acc.char_current_temperature is not None + + acc.run() + await hass.async_block_till_done() + + assert acc.char_current_humidity.value == 50 + assert acc.char_pm25_density.value == 10 + assert acc.char_air_quality.value == 2 + assert acc.char_current_temperature.value == 25 + + # Updated humidity should reflect in HomeKit + broker = MagicMock() + acc.char_current_humidity.broker = broker + hass.states.async_set( + humidity_entity_id, + 60, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 60 + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set( + humidity_entity_id, + 60, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + }, + force_update=True, + ) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 60 + assert len(broker.mock_calls) == 0 + + # Updated PM2.5 should reflect in HomeKit + broker = MagicMock() + acc.char_pm25_density.broker = broker + acc.char_air_quality.broker = broker + hass.states.async_set( + pm25_entity_id, + 5, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + }, + ) + await hass.async_block_till_done() + assert acc.char_pm25_density.value == 5 + assert acc.char_air_quality.value == 1 + assert len(broker.mock_calls) == 4 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set( + pm25_entity_id, + 5, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + }, + force_update=True, + ) + await hass.async_block_till_done() + assert acc.char_pm25_density.value == 5 + assert acc.char_air_quality.value == 1 + assert len(broker.mock_calls) == 0 + + # Updated temperature should reflect in HomeKit + broker = MagicMock() + acc.char_current_temperature.broker = broker + hass.states.async_set( + temperature_entity_id, + 30, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_temperature.value == 30 + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set( + temperature_entity_id, + 30, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + force_update=True, + ) + await hass.async_block_till_done() + assert acc.char_current_temperature.value == 30 + assert len(broker.mock_calls) == 0 + + # Should handle unavailable state, show last known value + hass.states.async_set( + humidity_entity_id, + STATE_UNAVAILABLE, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + }, + ) + hass.states.async_set( + pm25_entity_id, + STATE_UNAVAILABLE, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + }, + ) + hass.states.async_set( + temperature_entity_id, + STATE_UNAVAILABLE, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 60 + assert acc.char_pm25_density.value == 5 + assert acc.char_air_quality.value == 1 + assert acc.char_current_temperature.value == 30 + + # Check that all goes well if we remove the linked sensors + hass.states.async_remove(humidity_entity_id) + hass.states.async_remove(pm25_entity_id) + hass.states.async_remove(temperature_entity_id) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert len(acc.char_current_humidity.broker.mock_calls) == 0 + assert len(acc.char_pm25_density.broker.mock_calls) == 0 + assert len(acc.char_air_quality.broker.mock_calls) == 0 + assert len(acc.char_current_temperature.broker.mock_calls) == 0 + + # HomeKit will show the last known values + assert acc.char_current_humidity.value == 60 + assert acc.char_pm25_density.value == 5 + assert acc.char_air_quality.value == 1 + assert acc.char_current_temperature.value == 30 + + +async def test_filter_maintenance_linked_sensors( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test that a linked filter level and filter change indicator are exposed.""" + entity_id = "fan.demo" + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + }, + ) + + filter_change_indicator_entity_id = "binary_sensor.demo_filter_change_indicator" + hass.states.async_set(filter_change_indicator_entity_id, STATE_OFF) + + filter_life_level_entity_id = "sensor.demo_filter_life_level" + hass.states.async_set(filter_life_level_entity_id, 50) + + await hass.async_block_till_done() + acc = AirPurifier( + hass, + hk_driver, + "Air Purifier", + entity_id, + 1, + { + CONF_LINKED_FILTER_CHANGE_INDICATION: filter_change_indicator_entity_id, + CONF_LINKED_FILTER_LIFE_LEVEL: filter_life_level_entity_id, + }, + ) + hk_driver.add_accessory(acc) + + assert acc.linked_filter_change_indicator_binary_sensor is not None + assert acc.char_filter_change_indication is not None + assert acc.linked_filter_life_level_sensor is not None + assert acc.char_filter_life_level is not None + + acc.run() + await hass.async_block_till_done() + + assert acc.char_filter_change_indication.value == FILTER_OK + assert acc.char_filter_life_level.value == 50 + + # Updated filter change indicator should reflect in HomeKit + broker = MagicMock() + acc.char_filter_change_indication.broker = broker + hass.states.async_set(filter_change_indicator_entity_id, STATE_ON) + await hass.async_block_till_done() + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set( + filter_change_indicator_entity_id, STATE_ON, force_update=True + ) + await hass.async_block_till_done() + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + assert len(broker.mock_calls) == 0 + + # Updated filter life level should reflect in HomeKit + broker = MagicMock() + acc.char_filter_life_level.broker = broker + hass.states.async_set(filter_life_level_entity_id, 25) + await hass.async_block_till_done() + assert acc.char_filter_life_level.value == 25 + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set(filter_life_level_entity_id, 25, force_update=True) + await hass.async_block_till_done() + assert acc.char_filter_life_level.value == 25 + assert len(broker.mock_calls) == 0 + + # Should handle unavailable state, show last known value + hass.states.async_set(filter_change_indicator_entity_id, STATE_UNAVAILABLE) + hass.states.async_set(filter_life_level_entity_id, STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + assert acc.char_filter_life_level.value == 25 + + # Check that all goes well if we remove the linked sensors + hass.states.async_remove(filter_change_indicator_entity_id) + hass.states.async_remove(filter_life_level_entity_id) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert len(acc.char_filter_change_indication.broker.mock_calls) == 0 + assert len(acc.char_filter_life_level.broker.mock_calls) == 0 + + # HomeKit will show the last known values + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + assert acc.char_filter_life_level.value == 25 + + +async def test_filter_maintenance_only_change_indicator_sensor( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test that a linked filter change indicator is exposed.""" + entity_id = "fan.demo" + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + }, + ) + + filter_change_indicator_entity_id = "binary_sensor.demo_filter_change_indicator" + hass.states.async_set(filter_change_indicator_entity_id, STATE_OFF) + + await hass.async_block_till_done() + acc = AirPurifier( + hass, + hk_driver, + "Air Purifier", + entity_id, + 1, + { + CONF_LINKED_FILTER_CHANGE_INDICATION: filter_change_indicator_entity_id, + }, + ) + hk_driver.add_accessory(acc) + + assert acc.linked_filter_change_indicator_binary_sensor is not None + assert acc.char_filter_change_indication is not None + assert acc.linked_filter_life_level_sensor is None + + acc.run() + await hass.async_block_till_done() + + assert acc.char_filter_change_indication.value == FILTER_OK + + hass.states.async_set(filter_change_indicator_entity_id, STATE_ON) + await hass.async_block_till_done() + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + + +async def test_filter_life_level_linked_sensors( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test that a linked filter life level sensor exposed.""" + entity_id = "fan.demo" + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + }, + ) + + filter_life_level_entity_id = "sensor.demo_filter_life_level" + hass.states.async_set(filter_life_level_entity_id, 50) + + await hass.async_block_till_done() + acc = AirPurifier( + hass, + hk_driver, + "Air Purifier", + entity_id, + 1, + { + CONF_LINKED_FILTER_LIFE_LEVEL: filter_life_level_entity_id, + }, + ) + hk_driver.add_accessory(acc) + + assert acc.linked_filter_change_indicator_binary_sensor is None + assert ( + acc.char_filter_change_indication is not None + ) # calculated based on filter life level + assert acc.linked_filter_life_level_sensor is not None + assert acc.char_filter_life_level is not None + + acc.run() + await hass.async_block_till_done() + + assert acc.char_filter_change_indication.value == FILTER_OK + assert acc.char_filter_life_level.value == 50 + + hass.states.async_set( + filter_life_level_entity_id, THRESHOLD_FILTER_CHANGE_NEEDED - 1 + ) + await hass.async_block_till_done() + assert acc.char_filter_life_level.value == THRESHOLD_FILTER_CHANGE_NEEDED - 1 + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index 67392f11f14..e6f81c1729f 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -14,7 +14,13 @@ from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, FanEntityFeature, ) -from homeassistant.components.homekit.const import ATTR_VALUE, PROP_MIN_STEP +from homeassistant.components.homekit.accessories import HomeDriver +from homeassistant.components.homekit.const import ( + ATTR_VALUE, + CHAR_CONFIGURED_NAME, + PROP_MIN_STEP, + SERV_SWITCH, +) from homeassistant.components.homekit.type_fans import Fan from homeassistant.const import ( ATTR_ENTITY_ID, @@ -603,7 +609,7 @@ async def test_fan_restore( async def test_fan_multiple_preset_modes( - hass: HomeAssistant, hk_driver, events: list[Event] + hass: HomeAssistant, hk_driver: HomeDriver, events: list[Event] ) -> None: """Test fan with multiple preset modes.""" entity_id = "fan.demo" @@ -623,6 +629,9 @@ async def test_fan_multiple_preset_modes( assert acc.preset_mode_chars["auto"].value == 1 assert acc.preset_mode_chars["smart"].value == 0 + switch_service = acc.get_service(SERV_SWITCH) + configured_name_char = switch_service.get_characteristic(CHAR_CONFIGURED_NAME) + assert configured_name_char.value == "auto" acc.run() await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 78c35b15790..51d6e65bb1b 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -6,6 +6,7 @@ from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import ( ATTR_KEY_NAME, ATTR_VALUE, + CHAR_CONFIGURED_NAME, CHAR_REMOTE_KEY, CONF_FEATURE_LIST, EVENT_HOMEKIT_TV_REMOTE_KEY_PRESSED, @@ -14,6 +15,7 @@ from homeassistant.components.homekit.const import ( FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, KEY_ARROW_RIGHT, + SERV_SWITCH, ) from homeassistant.components.homekit.type_media_players import ( MediaPlayer, @@ -74,6 +76,10 @@ async def test_media_player_set_state( assert acc.aid == 2 assert acc.category == 8 # Switch + switch_service = acc.get_service(SERV_SWITCH) + configured_name_char = switch_service.get_characteristic(CHAR_CONFIGURED_NAME) + assert configured_name_char.value == "Power" + assert acc.chars[FEATURE_ON_OFF].value is False assert acc.chars[FEATURE_PLAY_PAUSE].value is False assert acc.chars[FEATURE_PLAY_STOP].value is False diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 6a30877a795..3f0f0a3c22b 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -6,6 +6,8 @@ import pytest from homeassistant.components.homekit.const import ( ATTR_VALUE, + CHAR_CONFIGURED_NAME, + SERV_OUTLET, TYPE_FAUCET, TYPE_SHOWER, TYPE_SPRINKLER, @@ -568,6 +570,10 @@ async def test_input_select_switch( acc.run() await hass.async_block_till_done() + switch_service = acc.get_service(SERV_OUTLET) + configured_name_char = switch_service.get_characteristic(CHAR_CONFIGURED_NAME) + assert configured_name_char.value == "option1" + assert acc.select_chars["option1"].value is True assert acc.select_chars["option2"].value is False assert acc.select_chars["option3"].value is False diff --git a/tests/components/homekit/test_type_triggers.py b/tests/components/homekit/test_type_triggers.py index f7415ef5599..87948d589c0 100644 --- a/tests/components/homekit/test_type_triggers.py +++ b/tests/components/homekit/test_type_triggers.py @@ -3,7 +3,11 @@ from unittest.mock import MagicMock from homeassistant.components.device_automation import DeviceAutomationType -from homeassistant.components.homekit.const import CHAR_PROGRAMMABLE_SWITCH_EVENT +from homeassistant.components.homekit.const import ( + CHAR_CONFIGURED_NAME, + CHAR_PROGRAMMABLE_SWITCH_EVENT, + SERV_STATELESS_PROGRAMMABLE_SWITCH, +) from homeassistant.components.homekit.type_triggers import DeviceTriggerAccessory from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -55,6 +59,10 @@ async def test_programmable_switch_button_fires_on_trigger( assert acc.device_id is device_id assert acc.available is True + switch_service = acc.get_service(SERV_STATELESS_PROGRAMMABLE_SWITCH) + configured_name_char = switch_service.get_characteristic(CHAR_CONFIGURED_NAME) + assert configured_name_char.value == "ceiling lights Changed States" + hk_driver.publish.reset_mock() hass.states.async_set("light.ceiling_lights", STATE_ON) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 1da12402a56..66906c72266 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -128,6 +128,7 @@ def test_validate_entity_config() -> None: } }, {"switch.test": {CONF_TYPE: "invalid_type"}}, + {"fan.test": {CONF_TYPE: "invalid_type"}}, ] for conf in configs: diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index 4e787f305b6..882d0d60e66 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -4,7 +4,9 @@ from collections.abc import Callable, Generator import datetime from unittest.mock import MagicMock, patch -from aiohomekit.testing import FakeController +from aiohomekit.model import Transport +from aiohomekit.testing import FakeController, FakeDiscovery, FakePairing +from bleak.backends.device import BLEDevice from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -57,3 +59,31 @@ def get_next_aid() -> Generator[Callable[[], int]]: return id_counter return _get_id + + +@pytest.fixture +def fake_ble_discovery() -> Generator[None]: + """Fake BLE discovery.""" + + class FakeBLEDiscovery(FakeDiscovery): + device = BLEDevice( + address="AA:BB:CC:DD:EE:FF", name="TestDevice", rssi=-50, details=() + ) + + with patch("aiohomekit.testing.FakeDiscovery", FakeBLEDiscovery): + yield + + +@pytest.fixture +def fake_ble_pairing() -> Generator[None]: + """Fake BLE pairing.""" + + class FakeBLEPairing(FakePairing): + """Fake BLE pairing.""" + + @property + def transport(self): + return Transport.BLE + + with patch("aiohomekit.testing.FakePairing", FakeBLEPairing): + yield diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 3bb9eb48106..324040f850f 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -86,7 +86,9 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + 'auto', + ]), }), 'categories': dict({ }), @@ -110,7 +112,7 @@ 'original_name': 'Airversa AP2 1808 AirPurifier', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_32832', 'unit_of_measurement': None, @@ -120,9 +122,11 @@ 'friendly_name': 'Airversa AP2 1808 AirPurifier', 'percentage': 0, 'percentage_step': 20.0, - 'preset_mode': None, - 'preset_modes': None, - 'supported_features': , + 'preset_mode': 'auto', + 'preset_modes': list([ + 'auto', + ]), + 'supported_features': , }), 'entity_id': 'fan.airversa_ap2_1808_airpurifier', 'state': 'off', @@ -10562,7 +10566,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -10597,7 +10602,8 @@ 'percentage': 66, 'percentage_step': 33.333333333333336, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.haa_c718b3', @@ -11248,7 +11254,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -11283,7 +11290,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.ceiling_fan', @@ -11458,7 +11466,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -11494,7 +11503,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.living_room_fan', @@ -11655,7 +11665,9 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + 'auto', + ]), }), 'categories': dict({ }), @@ -11679,7 +11691,7 @@ 'original_name': '89 Living Room', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_175', 'unit_of_measurement': None, @@ -11691,8 +11703,10 @@ 'percentage': 33, 'percentage_step': 33.333333333333336, 'preset_mode': None, - 'preset_modes': None, - 'supported_features': , + 'preset_modes': list([ + 'auto', + ]), + 'supported_features': , }), 'entity_id': 'fan.89_living_room', 'state': 'on', @@ -12703,7 +12717,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -12738,7 +12753,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.ceiling_fan', @@ -12913,7 +12929,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -12950,7 +12967,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.living_room_fan', @@ -13129,7 +13147,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -13166,7 +13185,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.living_room_fan', @@ -13336,7 +13356,9 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + 'auto', + ]), }), 'categories': dict({ }), @@ -13360,7 +13382,7 @@ 'original_name': '89 Living Room', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_175', 'unit_of_measurement': None, @@ -13372,8 +13394,10 @@ 'percentage': 33, 'percentage_step': 33.333333333333336, 'preset_mode': None, - 'preset_modes': None, - 'supported_features': , + 'preset_modes': list([ + 'auto', + ]), + 'supported_features': , }), 'entity_id': 'fan.89_living_room', 'state': 'on', @@ -17967,7 +17991,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -18002,7 +18027,8 @@ 'percentage': 0, 'percentage_step': 25.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.caseta_r_wireless_fan_speed_control', @@ -21777,7 +21803,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -21813,7 +21840,8 @@ 'percentage': 0, 'percentage_step': 25.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.simpleconnect_fan_06f674_hunter_fan', diff --git a/tests/components/homekit_controller/test_fan.py b/tests/components/homekit_controller/test_fan.py index 2c498e1a9c1..e012c1be339 100644 --- a/tests/components/homekit_controller/test_fan.py +++ b/tests/components/homekit_controller/test_fan.py @@ -47,6 +47,26 @@ def create_fanv2_service(accessory: Accessory) -> None: swing_mode.value = 0 +def create_fanv2_service_with_target_state(accessory: Accessory) -> None: + """Define fan v2 characteristics with target as per HAP spec.""" + service = accessory.add_service(ServicesTypes.FAN_V2) + + target_state = service.add_char(CharacteristicsTypes.FAN_STATE_TARGET) + target_state.value = 0 + + cur_state = service.add_char(CharacteristicsTypes.ACTIVE) + cur_state.value = 0 + + direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION) + direction.value = 0 + + speed = service.add_char(CharacteristicsTypes.ROTATION_SPEED) + speed.value = 0 + + swing_mode = service.add_char(CharacteristicsTypes.SWING_MODE) + swing_mode.value = 0 + + def create_fanv2_service_non_standard_rotation_range(accessory: Accessory) -> None: """Define fan v2 with a non-standard rotation range.""" service = accessory.add_service(ServicesTypes.FAN_V2) @@ -93,6 +113,27 @@ def create_fanv2_service_without_rotation_speed(accessory: Accessory) -> None: swing_mode.value = 0 +def create_air_purifier_service(accessory: Accessory) -> None: + """Define air purifier characteristics as per HAP spec.""" + service = accessory.add_service(ServicesTypes.AIR_PURIFIER) + + target_state = service.add_char(CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET) + target_state.value = 0 + + cur_state = service.add_char(CharacteristicsTypes.ACTIVE) + cur_state.value = 0 + + direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION) + direction.value = 0 + + speed = service.add_char(CharacteristicsTypes.ROTATION_SPEED) + speed.value = 0 + speed.minStep = 25 + + swing_mode = service.add_char(CharacteristicsTypes.SWING_MODE) + swing_mode.value = 0 + + async def test_fan_read_state( hass: HomeAssistant, get_next_aid: Callable[[], int] ) -> None: @@ -606,6 +647,70 @@ async def test_v2_set_percentage( ) +async def test_fanv2_set_preset_mode( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we set preset mode when target state is available.""" + helper = await setup_test_component( + hass, get_next_aid(), create_fanv2_service_with_target_state + ) + + await helper.async_update(ServicesTypes.FAN_V2, {CharacteristicsTypes.ACTIVE: 1}) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 100}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.FAN_V2, + { + CharacteristicsTypes.ROTATION_SPEED: 100.0, + }, + ) + + await hass.services.async_call( + "fan", + "set_preset_mode", + {"entity_id": "fan.testdevice", "preset_mode": "auto"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.FAN_V2, + { + CharacteristicsTypes.FAN_STATE_TARGET: 1, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 33}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.FAN_V2, + { + CharacteristicsTypes.ROTATION_SPEED: 33.0, + CharacteristicsTypes.FAN_STATE_TARGET: 0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "preset_mode": "auto"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.FAN_V2, + { + CharacteristicsTypes.FAN_STATE_TARGET: 1, + }, + ) + + async def test_v2_set_percentage_with_min_step( hass: HomeAssistant, get_next_aid: Callable[[], int] ) -> None: @@ -847,6 +952,281 @@ async def test_v2_set_percentage_non_standard_rotation_range( ) +async def test_air_purifier_turn_on( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we can turn on an air purifier.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "percentage": 100}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 1, + CharacteristicsTypes.ROTATION_SPEED: 100, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "percentage": 66}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 1, + CharacteristicsTypes.ROTATION_SPEED: 75.0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "percentage": 33}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 1, + CharacteristicsTypes.ROTATION_SPEED: 25.0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_off", + {"entity_id": "fan.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 0, + CharacteristicsTypes.ROTATION_SPEED: 25.0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 1, + CharacteristicsTypes.ROTATION_SPEED: 25.0, + }, + ) + + +async def test_air_purifier_turn_off( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we can turn an air purifier fan off.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await helper.async_update( + ServicesTypes.AIR_PURIFIER, {CharacteristicsTypes.ACTIVE: 1} + ) + + await hass.services.async_call( + "fan", + "turn_off", + {"entity_id": "fan.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 0, + }, + ) + + +async def test_air_purifier_set_speed( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we set air purifier fan speed.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await helper.async_update( + ServicesTypes.AIR_PURIFIER, {CharacteristicsTypes.ACTIVE: 1} + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 100}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 100, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 66}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 75.0, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 33}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 25.0, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 0}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 0, + }, + ) + + +async def test_air_purifier_set_percentage( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we set air purifier fan speed by percentage.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await helper.async_update( + ServicesTypes.AIR_PURIFIER, {CharacteristicsTypes.ACTIVE: 1} + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 75}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 75, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 0}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 0, + }, + ) + + +async def test_air_purifier_set_preset_mode( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we set preset mode when target state is available.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await helper.async_update( + ServicesTypes.AIR_PURIFIER, {CharacteristicsTypes.ACTIVE: 1} + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 100}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 100.0, + }, + ) + + await hass.services.async_call( + "fan", + "set_preset_mode", + {"entity_id": "fan.testdevice", "preset_mode": "auto"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET: 1, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 33}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 25.0, + CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET: 0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "preset_mode": "auto"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET: 1, + }, + ) + + async def test_migrate_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index f74e8ea994e..656978a08a2 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -174,6 +174,7 @@ async def test_offline_device_raises( assert hass.states.get("light.testdevice").state == STATE_OFF +@pytest.mark.usefixtures("fake_ble_discovery") async def test_ble_device_only_checks_is_available( hass: HomeAssistant, get_next_aid: Callable[[], int], controller ) -> None: @@ -242,6 +243,34 @@ async def test_ble_device_only_checks_is_available( assert hass.states.get("light.testdevice").state == STATE_OFF +@pytest.mark.usefixtures("fake_ble_discovery", "fake_ble_pairing") +async def test_ble_device_populates_connections( + hass: HomeAssistant, get_next_aid: Callable[[], int], controller +) -> None: + """Test a BLE device populates connections in the device registry.""" + aid = get_next_aid() + + accessory = Accessory.create_with_info( + aid, "TestDevice", "example.com", "Test", "0001", "0.1" + ) + create_alive_service(accessory) + + await async_setup_component(hass, DOMAIN, {}) + config_entry, _ = await setup_test_accessories_with_controller( + hass, [accessory], controller + ) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + dev_reg = dr.async_get(hass) + assert ( + dev_reg.async_get_device( + identifiers={}, connections={("bluetooth", "AA:BB:CC:DD:EE:FF")} + ) + is not None + ) + + @pytest.mark.parametrize("example", FIXTURES, ids=lambda val: str(val.stem)) async def test_snapshots( hass: HomeAssistant, diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index c40864c9629..3c8618c66c5 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -1,14 +1,12 @@ """Basic checks for HomeKit sensor.""" from collections.abc import Callable -from unittest.mock import patch -from aiohomekit.model import Accessory, Transport +from aiohomekit.model import Accessory from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.characteristics.const import ThreadNodeCapabilities, ThreadStatus from aiohomekit.model.services import Service, ServicesTypes from aiohomekit.protocol.statuscodes import HapStatusCode -from aiohomekit.testing import FakePairing import pytest from homeassistant.components.homekit_controller.sensor import ( @@ -406,34 +404,36 @@ def test_thread_status_to_str() -> None: assert thread_status_to_str(ThreadStatus.DISABLED) == "disabled" -@pytest.mark.usefixtures("enable_bluetooth", "entity_registry_enabled_by_default") +@pytest.mark.usefixtures( + "enable_bluetooth", + "entity_registry_enabled_by_default", + "fake_ble_discovery", + "fake_ble_pairing", +) async def test_rssi_sensor( hass: HomeAssistant, get_next_aid: Callable[[], int] ) -> None: """Test an rssi sensor.""" inject_bluetooth_service_info(hass, TEST_DEVICE_SERVICE_INFO) - class FakeBLEPairing(FakePairing): - """Fake BLE pairing.""" - - @property - def transport(self): - return Transport.BLE - - with patch("aiohomekit.testing.FakePairing", FakeBLEPairing): - # Any accessory will do for this test, but we need at least - # one or the rssi sensor will not be created - await setup_test_component( - hass, - get_next_aid(), - create_battery_level_sensor, - suffix="battery", - connection="BLE", - ) - assert hass.states.get("sensor.testdevice_signal_strength").state == "-56" + # Any accessory will do for this test, but we need at least + # one or the rssi sensor will not be created + await setup_test_component( + hass, + get_next_aid(), + create_battery_level_sensor, + suffix="battery", + connection="BLE", + ) + assert hass.states.get("sensor.testdevice_signal_strength").state == "-56" -@pytest.mark.usefixtures("enable_bluetooth", "entity_registry_enabled_by_default") +@pytest.mark.usefixtures( + "enable_bluetooth", + "entity_registry_enabled_by_default", + "fake_ble_discovery", + "fake_ble_pairing", +) async def test_migrate_rssi_sensor_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -449,24 +449,16 @@ async def test_migrate_rssi_sensor_unique_id( inject_bluetooth_service_info(hass, TEST_DEVICE_SERVICE_INFO) - class FakeBLEPairing(FakePairing): - """Fake BLE pairing.""" - - @property - def transport(self): - return Transport.BLE - - with patch("aiohomekit.testing.FakePairing", FakeBLEPairing): - # Any accessory will do for this test, but we need at least - # one or the rssi sensor will not be created - await setup_test_component( - hass, - get_next_aid(), - create_battery_level_sensor, - suffix="battery", - connection="BLE", - ) - assert hass.states.get("sensor.renamed_rssi").state == "-56" + # Any accessory will do for this test, but we need at least + # one or the rssi sensor will not be created + await setup_test_component( + hass, + get_next_aid(), + create_battery_level_sensor, + suffix="battery", + connection="BLE", + ) + assert hass.states.get("sensor.renamed_rssi").state == "-56" assert ( entity_registry.async_get(rssi_sensor.entity_id).unique_id diff --git a/tests/components/homematicip_cloud/conftest.py b/tests/components/homematicip_cloud/conftest.py index ad3957fea69..8672dfedd13 100644 --- a/tests/components/homematicip_cloud/conftest.py +++ b/tests/components/homematicip_cloud/conftest.py @@ -1,11 +1,11 @@ """Initializer helpers for HomematicIP fake server.""" -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, Mock, patch -from homematicip.aio.auth import AsyncAuth -from homematicip.aio.connection import AsyncConnection -from homematicip.aio.home import AsyncHome +from homematicip.async_home import AsyncHome +from homematicip.auth import Auth from homematicip.base.enums import WeatherCondition, WeatherDayTime +from homematicip.connection.rest_connection import RestConnection import pytest from homeassistant.components.homematicip_cloud import ( @@ -30,16 +30,14 @@ from tests.components.light.conftest import mock_light_profiles # noqa: F401 @pytest.fixture(name="mock_connection") -def mock_connection_fixture() -> AsyncConnection: +def mock_connection_fixture() -> RestConnection: """Return a mocked connection.""" - connection = MagicMock(spec=AsyncConnection) + connection = AsyncMock(spec=RestConnection) - def _rest_call_side_effect(path, body=None): + def _rest_call_side_effect(path, body=None, custom_header=None): return path, body - connection._rest_call.side_effect = _rest_call_side_effect - connection.api_call = AsyncMock(return_value=True) - connection.init = AsyncMock(side_effect=True) + connection.async_post.side_effect = _rest_call_side_effect return connection @@ -107,7 +105,7 @@ async def mock_hap_with_service_fixture( def simple_mock_home_fixture(): """Return a simple mocked connection.""" - mock_home = Mock( + mock_home = AsyncMock( spec=AsyncHome, name="Demo", devices=[], @@ -128,6 +126,8 @@ def simple_mock_home_fixture(): dutyCycle=88, connected=True, currentAPVersion="2.0.36", + init_async=AsyncMock(), + get_current_state_async=AsyncMock(), ) with patch( @@ -144,18 +144,15 @@ def mock_connection_init_fixture(): with ( patch( - "homeassistant.components.homematicip_cloud.hap.AsyncHome.init", - return_value=None, - ), - patch( - "homeassistant.components.homematicip_cloud.hap.AsyncAuth.init", + "homeassistant.components.homematicip_cloud.hap.AsyncHome.init_async", return_value=None, + new_callable=AsyncMock, ), ): yield @pytest.fixture(name="simple_mock_auth") -def simple_mock_auth_fixture() -> AsyncAuth: +def simple_mock_auth_fixture() -> Auth: """Return a simple AsyncAuth Mock.""" - return Mock(spec=AsyncAuth, pin=HAPPIN, create=True) + return AsyncMock(spec=Auth, pin=HAPPIN, create=True) diff --git a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json index ff57cd168c9..65f8afe55fa 100644 --- a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json +++ b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json @@ -8296,6 +8296,130 @@ "serializedGlobalTradeItemNumber": "3014F7110000000000DSDPCB", "type": "DOOR_BELL_CONTACT_INTERFACE", "updateState": "UP_TO_DATE" + }, + "3014F71100000000000SVCTH": { + "availableFirmwareVersion": "1.0.10", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "firmwareVersion": "1.0.10", + "firmwareVersionInteger": 65546, + "functionalChannels": { + "0": { + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "controlsMountingOrientation": null, + "daliBusState": null, + "defaultLinkedGroup": [], + "deviceAliveSignalEnabled": null, + "deviceCommunicationError": null, + "deviceDriveError": null, + "deviceDriveModeError": null, + "deviceId": "3014F71100000000000SVCTH", + "deviceOperationMode": null, + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "displayContrast": null, + "displayMode": null, + "displayMountingOrientation": null, + "dutyCycle": false, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 0, + "invertedDisplayColors": null, + "label": "", + "lockJammed": null, + "lowBat": false, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "operationDays": null, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": null, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -84, + "rssiPeerValue": null, + "sensorCommunicationError": null, + "sensorError": null, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": false, + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceCommunicationError": false, + "IFeatureDeviceDaliBusError": false, + "IFeatureDeviceDriveError": false, + "IFeatureDeviceDriveModeError": false, + "IFeatureDeviceIdentify": false, + "IFeatureDeviceOverheated": false, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceParticulateMatterSensorCommunicationError": false, + "IFeatureDeviceParticulateMatterSensorError": false, + "IFeatureDevicePowerFailure": false, + "IFeatureDeviceSensorCommunicationError": false, + "IFeatureDeviceSensorError": false, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": false, + "IFeatureDeviceTemperatureHumiditySensorError": false, + "IFeatureDeviceTemperatureOutOfRange": true, + "IFeatureDeviceUndervoltage": false, + "IFeatureMulticastRouter": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": false, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IOptionalFeatureDefaultLinkedGroup": false, + "IOptionalFeatureDeviceAliveSignalEnabled": false, + "IOptionalFeatureDeviceErrorLockJammed": false, + "IOptionalFeatureDeviceOperationMode": false, + "IOptionalFeatureDisplayContrast": false, + "IOptionalFeatureDisplayMode": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureInvertedDisplayColors": false, + "IOptionalFeatureLowBat": true, + "IOptionalFeatureMountingOrientation": false, + "IOptionalFeatureOperationDays": false + }, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "unreach": false + }, + "1": { + "actualTemperature": 19.7, + "channelRole": "WEATHER_SENSOR", + "deviceId": "3014F71100000000000SVCTH", + "functionalChannelType": "CLIMATE_SENSOR_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000035"], + "humidity": 36, + "index": 1, + "label": "", + "vaporAmount": 6.098938251390021 + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F71100000000000SVCTH", + "label": "elvshctv", + "lastStatusUpdate": 1744114372880, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manuallyUpdateForced": false, + "manufacturerCode": 9, + "measuredAttributes": {}, + "modelId": 555, + "modelType": "ELV-SH-CTH", + "oem": "eQ-3", + "permanentlyReachable": false, + "serializedGlobalTradeItemNumber": "3014F71100000000000SVCTH", + "type": "TEMPERATURE_HUMIDITY_SENSOR_COMPACT", + "updateState": "UP_TO_DATE" } }, "groups": { diff --git a/tests/components/homematicip_cloud/helper.py b/tests/components/homematicip_cloud/helper.py index 80081123519..78c03c6847c 100644 --- a/tests/components/homematicip_cloud/helper.py +++ b/tests/components/homematicip_cloud/helper.py @@ -4,15 +4,15 @@ import json from typing import Any from unittest.mock import Mock, patch -from homematicip.aio.class_maps import ( +from homematicip.async_home import AsyncHome +from homematicip.base.homematicip_object import HomeMaticIPObject +from homematicip.class_maps import ( TYPE_CLASS_MAP, TYPE_GROUP_MAP, TYPE_SECURITY_EVENT_MAP, ) -from homematicip.aio.device import AsyncDevice -from homematicip.aio.group import AsyncGroup -from homematicip.aio.home import AsyncHome -from homematicip.base.homematicip_object import HomeMaticIPObject +from homematicip.device import Device +from homematicip.group import Group from homematicip.home import Home from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN @@ -49,9 +49,9 @@ def get_and_check_entity_basics( hmip_device = mock_hap.hmip_device_by_entity_id.get(entity_id) if hmip_device: - if isinstance(hmip_device, AsyncDevice): + if isinstance(hmip_device, Device): assert ha_state.attributes[ATTR_IS_GROUP] is False - elif isinstance(hmip_device, AsyncGroup): + elif isinstance(hmip_device, Group): assert ha_state.attributes[ATTR_IS_GROUP] return ha_state, hmip_device @@ -174,12 +174,12 @@ class HomeTemplate(Home): def init_home(self): """Init template with json.""" self.init_json_state = self._cleanup_json(json.loads(FIXTURE_DATA)) - self.update_home(json_state=self.init_json_state, clearConfig=True) + self.update_home(json_state=self.init_json_state, clear_config=True) return self - def update_home(self, json_state, clearConfig: bool = False): + def update_home(self, json_state, clear_config: bool = False): """Update home and ensure that mocks are created.""" - result = super().update_home(json_state, clearConfig) + result = super().update_home(json_state, clear_config) self._generate_mocks() return result @@ -193,7 +193,7 @@ class HomeTemplate(Home): self.groups = [_get_mock(group) for group in self.groups] - def download_configuration(self): + async def download_configuration_async(self): """Return the initial json config.""" return self.init_json_state diff --git a/tests/components/homematicip_cloud/test_alarm_control_panel.py b/tests/components/homematicip_cloud/test_alarm_control_panel.py index 094308862f6..853660ceac6 100644 --- a/tests/components/homematicip_cloud/test_alarm_control_panel.py +++ b/tests/components/homematicip_cloud/test_alarm_control_panel.py @@ -1,6 +1,6 @@ """Tests for HomematicIP Cloud alarm control panel.""" -from homematicip.aio.home import AsyncHome +from homematicip.async_home import AsyncHome from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, @@ -73,7 +73,7 @@ async def test_hmip_alarm_control_panel( await hass.services.async_call( "alarm_control_panel", "alarm_arm_away", {"entity_id": entity_id}, blocking=True ) - assert home.mock_calls[-1][0] == "set_security_zones_activation" + assert home.mock_calls[-1][0] == "set_security_zones_activation_async" assert home.mock_calls[-1][1] == (True, True) await _async_manipulate_security_zones( hass, home, internal_active=True, external_active=True @@ -83,7 +83,7 @@ async def test_hmip_alarm_control_panel( await hass.services.async_call( "alarm_control_panel", "alarm_arm_home", {"entity_id": entity_id}, blocking=True ) - assert home.mock_calls[-1][0] == "set_security_zones_activation" + assert home.mock_calls[-1][0] == "set_security_zones_activation_async" assert home.mock_calls[-1][1] == (False, True) await _async_manipulate_security_zones(hass, home, external_active=True) assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMED_HOME @@ -91,7 +91,7 @@ async def test_hmip_alarm_control_panel( await hass.services.async_call( "alarm_control_panel", "alarm_disarm", {"entity_id": entity_id}, blocking=True ) - assert home.mock_calls[-1][0] == "set_security_zones_activation" + assert home.mock_calls[-1][0] == "set_security_zones_activation_async" assert home.mock_calls[-1][1] == (False, False) await _async_manipulate_security_zones(hass, home) assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED @@ -99,7 +99,7 @@ async def test_hmip_alarm_control_panel( await hass.services.async_call( "alarm_control_panel", "alarm_arm_away", {"entity_id": entity_id}, blocking=True ) - assert home.mock_calls[-1][0] == "set_security_zones_activation" + assert home.mock_calls[-1][0] == "set_security_zones_activation_async" assert home.mock_calls[-1][1] == (True, True) await _async_manipulate_security_zones( hass, home, internal_active=True, external_active=True, alarm_triggered=True @@ -109,7 +109,7 @@ async def test_hmip_alarm_control_panel( await hass.services.async_call( "alarm_control_panel", "alarm_arm_home", {"entity_id": entity_id}, blocking=True ) - assert home.mock_calls[-1][0] == "set_security_zones_activation" + assert home.mock_calls[-1][0] == "set_security_zones_activation_async" assert home.mock_calls[-1][1] == (False, True) await _async_manipulate_security_zones( hass, home, external_active=True, alarm_triggered=True diff --git a/tests/components/homematicip_cloud/test_climate.py b/tests/components/homematicip_cloud/test_climate.py index d4711440288..c39d4fa2d99 100644 --- a/tests/components/homematicip_cloud/test_climate.py +++ b/tests/components/homematicip_cloud/test_climate.py @@ -83,7 +83,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_point_temperature" + assert hmip_device.mock_calls[-1][0] == "set_point_temperature_async" assert hmip_device.mock_calls[-1][1] == (22.5,) await async_manipulate_test_data(hass, hmip_device, "actualTemperature", 22.5) ha_state = hass.states.get(entity_id) @@ -96,7 +96,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("MANUAL",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "MANUAL") ha_state = hass.states.get(entity_id) @@ -109,7 +109,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("AUTOMATIC",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "AUTO") ha_state = hass.states.get(entity_id) @@ -122,7 +122,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 7 - assert hmip_device.mock_calls[-1][0] == "set_boost" + assert hmip_device.mock_calls[-1][0] == "set_boost_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "boostMode", True) ha_state = hass.states.get(entity_id) @@ -135,7 +135,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 11 - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (0,) await async_manipulate_test_data(hass, hmip_device, "boostMode", False) ha_state = hass.states.get(entity_id) @@ -176,7 +176,7 @@ async def test_hmip_heating_group_heat( ) assert len(hmip_device.mock_calls) == service_call_counter + 18 - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (1,) mock_hap.home.get_functionalHome( @@ -194,7 +194,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 20 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("MANUAL",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "MANUAL") ha_state = hass.states.get(entity_id) @@ -208,7 +208,7 @@ async def test_hmip_heating_group_heat( ) assert len(hmip_device.mock_calls) == service_call_counter + 23 - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (1,) hmip_device.activeProfile = hmip_device.profiles[0] await async_manipulate_test_data(hass, hmip_device, "controlMode", "AUTOMATIC") @@ -235,7 +235,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 25 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("ECO",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "ECO") ha_state = hass.states.get(entity_id) @@ -293,7 +293,7 @@ async def test_hmip_heating_group_cool( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("MANUAL",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "MANUAL") ha_state = hass.states.get(entity_id) @@ -306,7 +306,7 @@ async def test_hmip_heating_group_cool( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("AUTOMATIC",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "AUTO") ha_state = hass.states.get(entity_id) @@ -320,7 +320,7 @@ async def test_hmip_heating_group_cool( ) assert len(hmip_device.mock_calls) == service_call_counter + 6 - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (4,) hmip_device.activeProfile = hmip_device.profiles[4] @@ -373,7 +373,7 @@ async def test_hmip_heating_group_cool( ) assert len(hmip_device.mock_calls) == service_call_counter + 17 - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (4,) @@ -531,7 +531,7 @@ async def test_hmip_climate_services( {"duration": 60, "accesspoint_id": HAPID}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_absence_with_duration" + assert home.mock_calls[-1][0] == "activate_absence_with_duration_async" assert home.mock_calls[-1][1] == (60,) assert len(home._connection.mock_calls) == 1 @@ -541,7 +541,7 @@ async def test_hmip_climate_services( {"duration": 60}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_absence_with_duration" + assert home.mock_calls[-1][0] == "activate_absence_with_duration_async" assert home.mock_calls[-1][1] == (60,) assert len(home._connection.mock_calls) == 2 @@ -551,7 +551,7 @@ async def test_hmip_climate_services( {"endtime": "2019-02-17 14:00", "accesspoint_id": HAPID}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_absence_with_period" + assert home.mock_calls[-1][0] == "activate_absence_with_period_async" assert home.mock_calls[-1][1] == (datetime.datetime(2019, 2, 17, 14, 0),) assert len(home._connection.mock_calls) == 3 @@ -561,7 +561,7 @@ async def test_hmip_climate_services( {"endtime": "2019-02-17 14:00"}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_absence_with_period" + assert home.mock_calls[-1][0] == "activate_absence_with_period_async" assert home.mock_calls[-1][1] == (datetime.datetime(2019, 2, 17, 14, 0),) assert len(home._connection.mock_calls) == 4 @@ -571,7 +571,7 @@ async def test_hmip_climate_services( {"endtime": "2019-02-17 14:00", "temperature": 18.5, "accesspoint_id": HAPID}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_vacation" + assert home.mock_calls[-1][0] == "activate_vacation_async" assert home.mock_calls[-1][1] == (datetime.datetime(2019, 2, 17, 14, 0), 18.5) assert len(home._connection.mock_calls) == 5 @@ -581,7 +581,7 @@ async def test_hmip_climate_services( {"endtime": "2019-02-17 14:00", "temperature": 18.5}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_vacation" + assert home.mock_calls[-1][0] == "activate_vacation_async" assert home.mock_calls[-1][1] == (datetime.datetime(2019, 2, 17, 14, 0), 18.5) assert len(home._connection.mock_calls) == 6 @@ -591,14 +591,14 @@ async def test_hmip_climate_services( {"accesspoint_id": HAPID}, blocking=True, ) - assert home.mock_calls[-1][0] == "deactivate_absence" + assert home.mock_calls[-1][0] == "deactivate_absence_async" assert home.mock_calls[-1][1] == () assert len(home._connection.mock_calls) == 7 await hass.services.async_call( "homematicip_cloud", "deactivate_eco_mode", blocking=True ) - assert home.mock_calls[-1][0] == "deactivate_absence" + assert home.mock_calls[-1][0] == "deactivate_absence_async" assert home.mock_calls[-1][1] == () assert len(home._connection.mock_calls) == 8 @@ -608,14 +608,14 @@ async def test_hmip_climate_services( {"accesspoint_id": HAPID}, blocking=True, ) - assert home.mock_calls[-1][0] == "deactivate_vacation" + assert home.mock_calls[-1][0] == "deactivate_vacation_async" assert home.mock_calls[-1][1] == () assert len(home._connection.mock_calls) == 9 await hass.services.async_call( "homematicip_cloud", "deactivate_vacation", blocking=True ) - assert home.mock_calls[-1][0] == "deactivate_vacation" + assert home.mock_calls[-1][0] == "deactivate_vacation_async" assert home.mock_calls[-1][1] == () assert len(home._connection.mock_calls) == 10 @@ -646,7 +646,7 @@ async def test_hmip_set_home_cooling_mode( {"accesspoint_id": HAPID, "cooling": False}, blocking=True, ) - assert home.mock_calls[-1][0] == "set_cooling" + assert home.mock_calls[-1][0] == "set_cooling_async" assert home.mock_calls[-1][1] == (False,) assert len(home._connection.mock_calls) == 1 @@ -656,14 +656,14 @@ async def test_hmip_set_home_cooling_mode( {"accesspoint_id": HAPID, "cooling": True}, blocking=True, ) - assert home.mock_calls[-1][0] == "set_cooling" + assert home.mock_calls[-1][0] == "set_cooling_async" assert home.mock_calls[-1][1] assert len(home._connection.mock_calls) == 2 await hass.services.async_call( "homematicip_cloud", "set_home_cooling_mode", blocking=True ) - assert home.mock_calls[-1][0] == "set_cooling" + assert home.mock_calls[-1][0] == "set_cooling_async" assert home.mock_calls[-1][1] assert len(home._connection.mock_calls) == 3 @@ -703,9 +703,9 @@ async def test_hmip_heating_group_services( {"climate_profile_index": 2, "entity_id": "climate.badezimmer"}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (1,) - assert len(hmip_device._connection.mock_calls) == 2 + assert len(hmip_device._connection.mock_calls) == 1 await hass.services.async_call( "homematicip_cloud", @@ -713,6 +713,6 @@ async def test_hmip_heating_group_services( {"climate_profile_index": 2, "entity_id": "all"}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (1,) - assert len(hmip_device._connection.mock_calls) == 4 + assert len(hmip_device._connection.mock_calls) == 2 diff --git a/tests/components/homematicip_cloud/test_cover.py b/tests/components/homematicip_cloud/test_cover.py index bcafa689172..aa104da0546 100644 --- a/tests/components/homematicip_cloud/test_cover.py +++ b/tests/components/homematicip_cloud/test_cover.py @@ -47,7 +47,7 @@ async def test_hmip_cover_shutter( "cover", "open_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (0, 1) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0) ha_state = hass.states.get(entity_id) @@ -61,7 +61,7 @@ async def test_hmip_cover_shutter( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (0.5, 1) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0.5) ha_state = hass.states.get(entity_id) @@ -72,7 +72,7 @@ async def test_hmip_cover_shutter( "cover", "close_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (1, 1) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 1) ha_state = hass.states.get(entity_id) @@ -83,7 +83,7 @@ async def test_hmip_cover_shutter( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 7 - assert hmip_device.mock_calls[-1][0] == "set_shutter_stop" + assert hmip_device.mock_calls[-1][0] == "set_shutter_stop_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", None) @@ -115,7 +115,7 @@ async def test_hmip_cover_slats( "cover", "open_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 1, "slatsLevel": 0} await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0) @@ -131,7 +131,7 @@ async def test_hmip_cover_slats( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 1, "slatsLevel": 0.5} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0.5) ha_state = hass.states.get(entity_id) @@ -143,7 +143,7 @@ async def test_hmip_cover_slats( "cover", "close_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 6 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 1, "slatsLevel": 1} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 1) ha_state = hass.states.get(entity_id) @@ -155,7 +155,7 @@ async def test_hmip_cover_slats( "cover", "stop_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 8 - assert hmip_device.mock_calls[-1][0] == "set_shutter_stop" + assert hmip_device.mock_calls[-1][0] == "set_shutter_stop_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", None) @@ -195,7 +195,7 @@ async def test_hmip_multi_cover_slats( "cover", "open_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 4, "slatsLevel": 0} await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0, channel=4) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0, channel=4) @@ -211,7 +211,7 @@ async def test_hmip_multi_cover_slats( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 4, "slatsLevel": 0.5} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0.5, channel=4) ha_state = hass.states.get(entity_id) @@ -223,7 +223,7 @@ async def test_hmip_multi_cover_slats( "cover", "close_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 6 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 4, "slatsLevel": 1} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 1, channel=4) ha_state = hass.states.get(entity_id) @@ -235,7 +235,7 @@ async def test_hmip_multi_cover_slats( "cover", "stop_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 8 - assert hmip_device.mock_calls[-1][0] == "set_shutter_stop" + assert hmip_device.mock_calls[-1][0] == "set_shutter_stop_async" assert hmip_device.mock_calls[-1][1] == (4,) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", None, channel=4) @@ -271,7 +271,7 @@ async def test_hmip_blind_module( "cover", "open_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_secondary_shading_level" + assert hmip_device.mock_calls[-1][0] == "set_secondary_shading_level_async" assert hmip_device.mock_calls[-1][2] == { "primaryShadingLevel": 0.94956, "secondaryShadingLevel": 0, @@ -284,7 +284,7 @@ async def test_hmip_blind_module( ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_primary_shading_level" + assert hmip_device.mock_calls[-1][0] == "set_primary_shading_level_async" assert hmip_device.mock_calls[-1][2] == {"primaryShadingLevel": 0} ha_state = hass.states.get(entity_id) @@ -308,7 +308,7 @@ async def test_hmip_blind_module( ) assert len(hmip_device.mock_calls) == service_call_counter + 8 - assert hmip_device.mock_calls[-1][0] == "set_primary_shading_level" + assert hmip_device.mock_calls[-1][0] == "set_primary_shading_level_async" assert hmip_device.mock_calls[-1][2] == {"primaryShadingLevel": 0.5} ha_state = hass.states.get(entity_id) assert ha_state.state == CoverState.OPEN @@ -325,7 +325,7 @@ async def test_hmip_blind_module( ) assert len(hmip_device.mock_calls) == service_call_counter + 12 - assert hmip_device.mock_calls[-1][0] == "set_secondary_shading_level" + assert hmip_device.mock_calls[-1][0] == "set_secondary_shading_level_async" assert hmip_device.mock_calls[-1][2] == { "primaryShadingLevel": 1, "secondaryShadingLevel": 1, @@ -340,14 +340,14 @@ async def test_hmip_blind_module( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 13 - assert hmip_device.mock_calls[-1][0] == "stop" + assert hmip_device.mock_calls[-1][0] == "stop_async" assert hmip_device.mock_calls[-1][1] == () await hass.services.async_call( "cover", "stop_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 14 - assert hmip_device.mock_calls[-1][0] == "stop" + assert hmip_device.mock_calls[-1][0] == "stop_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "secondaryShadingLevel", None) @@ -382,7 +382,7 @@ async def test_hmip_garage_door_tormatic( "cover", "open_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.OPEN,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.OPEN) ha_state = hass.states.get(entity_id) @@ -393,7 +393,7 @@ async def test_hmip_garage_door_tormatic( "cover", "close_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.CLOSE,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.CLOSED) ha_state = hass.states.get(entity_id) @@ -404,7 +404,7 @@ async def test_hmip_garage_door_tormatic( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.STOP,) @@ -431,7 +431,7 @@ async def test_hmip_garage_door_hoermann( "cover", "open_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.OPEN,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.OPEN) ha_state = hass.states.get(entity_id) @@ -442,7 +442,7 @@ async def test_hmip_garage_door_hoermann( "cover", "close_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.CLOSE,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.CLOSED) ha_state = hass.states.get(entity_id) @@ -453,7 +453,7 @@ async def test_hmip_garage_door_hoermann( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.STOP,) @@ -478,7 +478,7 @@ async def test_hmip_cover_shutter_group( "cover", "open_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (0,) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0) ha_state = hass.states.get(entity_id) @@ -492,7 +492,7 @@ async def test_hmip_cover_shutter_group( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (0.5,) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0.5) ha_state = hass.states.get(entity_id) @@ -503,7 +503,7 @@ async def test_hmip_cover_shutter_group( "cover", "close_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 1) ha_state = hass.states.get(entity_id) @@ -514,7 +514,7 @@ async def test_hmip_cover_shutter_group( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 7 - assert hmip_device.mock_calls[-1][0] == "set_shutter_stop" + assert hmip_device.mock_calls[-1][0] == "set_shutter_stop_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "shutterLevel", None) @@ -553,7 +553,7 @@ async def test_hmip_cover_slats_group( ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][1] == (0,) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0.5) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0) @@ -569,7 +569,7 @@ async def test_hmip_cover_slats_group( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][1] == (0.5,) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0.5) ha_state = hass.states.get(entity_id) @@ -581,7 +581,7 @@ async def test_hmip_cover_slats_group( "cover", "close_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 7 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 1) ha_state = hass.states.get(entity_id) @@ -593,5 +593,5 @@ async def test_hmip_cover_slats_group( "cover", "stop_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 9 - assert hmip_device.mock_calls[-1][0] == "set_shutter_stop" + assert hmip_device.mock_calls[-1][0] == "set_shutter_stop_async" assert hmip_device.mock_calls[-1][1] == () diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 5ec37d8d8f5..fd72f275489 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) == 310 + assert len(mock_hap.hmip_device_by_entity_id) == 325 async def test_hmip_remove_device( @@ -257,14 +257,14 @@ async def test_hmip_reset_energy_counter_services( {"entity_id": "switch.pc"}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "reset_energy_counter" - assert len(hmip_device._connection.mock_calls) == 2 + assert hmip_device.mock_calls[-1][0] == "reset_energy_counter_async" + assert len(hmip_device._connection.mock_calls) == 1 await hass.services.async_call( "homematicip_cloud", "reset_energy_counter", {"entity_id": "all"}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "reset_energy_counter" - assert len(hmip_device._connection.mock_calls) == 4 + assert hmip_device.mock_calls[-1][0] == "reset_energy_counter_async" + assert len(hmip_device._connection.mock_calls) == 2 async def test_hmip_multi_area_device( diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index ded1bf88292..e34424d3439 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -2,8 +2,9 @@ from unittest.mock import Mock, patch -from homematicip.aio.auth import AsyncAuth -from homematicip.base.base_connection import HmipConnectionError +from homematicip.auth import Auth +from homematicip.connection.connection_context import ConnectionContext +from homematicip.exceptions.connection_exceptions import HmipConnectionError import pytest from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN @@ -48,13 +49,13 @@ async def test_auth_auth_check_and_register(hass: HomeAssistant) -> None: config = {HMIPC_HAPID: "ABC123", HMIPC_PIN: "123", HMIPC_NAME: "hmip"} hmip_auth = HomematicipAuth(hass, config) - hmip_auth.auth = Mock(spec=AsyncAuth) + hmip_auth.auth = Mock(spec=Auth) with ( - patch.object(hmip_auth.auth, "isRequestAcknowledged", return_value=True), - patch.object(hmip_auth.auth, "requestAuthToken", return_value="ABC"), + patch.object(hmip_auth.auth, "is_request_acknowledged", return_value=True), + patch.object(hmip_auth.auth, "request_auth_token", return_value="ABC"), patch.object( hmip_auth.auth, - "confirmAuthToken", + "confirm_auth_token", ), ): assert await hmip_auth.async_checkbutton() @@ -65,13 +66,13 @@ async def test_auth_auth_check_and_register_with_exception(hass: HomeAssistant) """Test auth client registration.""" config = {HMIPC_HAPID: "ABC123", HMIPC_PIN: "123", HMIPC_NAME: "hmip"} hmip_auth = HomematicipAuth(hass, config) - hmip_auth.auth = Mock(spec=AsyncAuth) + hmip_auth.auth = Mock(spec=Auth) with ( patch.object( - hmip_auth.auth, "isRequestAcknowledged", side_effect=HmipConnectionError + hmip_auth.auth, "is_request_acknowledged", side_effect=HmipConnectionError ), patch.object( - hmip_auth.auth, "requestAuthToken", side_effect=HmipConnectionError + hmip_auth.auth, "request_auth_token", side_effect=HmipConnectionError ), ): assert not await hmip_auth.async_checkbutton() @@ -135,7 +136,13 @@ async def test_hap_create( hass.config.components.add(HMIPC_DOMAIN) hap = HomematicipHAP(hass, hmip_config_entry) assert hap - with patch.object(hap, "async_connect"): + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), + patch.object(hap, "async_connect"), + ): async with hmip_config_entry.setup_lock: assert await hap.async_setup() @@ -149,15 +156,25 @@ async def test_hap_create_exception( hap = HomematicipHAP(hass, hmip_config_entry) assert hap - with patch( - "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state", - side_effect=Exception, + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), + patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state_async", + side_effect=Exception, + ), ): assert not await hap.async_setup() with ( patch( - "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state", + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), + patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state_async", side_effect=HmipConnectionError, ), pytest.raises(ConfigEntryNotReady), @@ -171,9 +188,15 @@ async def test_auth_create(hass: HomeAssistant, simple_mock_auth) -> None: hmip_auth = HomematicipAuth(hass, config) assert hmip_auth - with patch( - "homeassistant.components.homematicip_cloud.hap.AsyncAuth", - return_value=simple_mock_auth, + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.Auth", + return_value=simple_mock_auth, + ), + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), ): assert await hmip_auth.async_setup() await hass.async_block_till_done() @@ -184,16 +207,28 @@ async def test_auth_create_exception(hass: HomeAssistant, simple_mock_auth) -> N """Mock AsyncAuth to execute get_auth.""" config = {HMIPC_HAPID: HAPID, HMIPC_PIN: HAPPIN, HMIPC_NAME: "hmip"} hmip_auth = HomematicipAuth(hass, config) - simple_mock_auth.connectionRequest.side_effect = HmipConnectionError + simple_mock_auth.connection_request.side_effect = HmipConnectionError assert hmip_auth - with patch( - "homeassistant.components.homematicip_cloud.hap.AsyncAuth", - return_value=simple_mock_auth, + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.Auth", + return_value=simple_mock_auth, + ), + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), ): assert not await hmip_auth.async_setup() - with patch( - "homeassistant.components.homematicip_cloud.hap.AsyncAuth", - return_value=simple_mock_auth, + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.Auth", + return_value=simple_mock_auth, + ), + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), ): assert not await hmip_auth.get_auth(hass, HAPID, HAPPIN) diff --git a/tests/components/homematicip_cloud/test_init.py b/tests/components/homematicip_cloud/test_init.py index 07c53248d92..f28b3870705 100644 --- a/tests/components/homematicip_cloud/test_init.py +++ b/tests/components/homematicip_cloud/test_init.py @@ -2,7 +2,8 @@ from unittest.mock import AsyncMock, Mock, patch -from homematicip.base.base_connection import HmipConnectionError +from homematicip.connection.connection_context import ConnectionContext +from homematicip.exceptions.connection_exceptions import HmipConnectionError from homeassistant.components.homematicip_cloud.const import ( CONF_ACCESSPOINT, @@ -105,9 +106,15 @@ async def test_load_entry_fails_due_to_connection_error( """Test load entry fails due to connection error.""" hmip_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state", - side_effect=HmipConnectionError, + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state_async", + side_effect=HmipConnectionError, + ), + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), ): assert await async_setup_component(hass, HMIPC_DOMAIN, {}) @@ -123,12 +130,9 @@ async def test_load_entry_fails_due_to_generic_exception( with ( patch( - "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state", + "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state_async", side_effect=Exception, ), - patch( - "homematicip.aio.connection.AsyncConnection.init", - ), ): assert await async_setup_component(hass, HMIPC_DOMAIN, {}) @@ -175,7 +179,7 @@ async def test_hmip_dump_hap_config_services( "homematicip_cloud", "dump_hap_config", {"anonymize": True}, blocking=True ) home = mock_hap_with_service.home - assert home.mock_calls[-1][0] == "download_configuration" + assert home.mock_calls[-1][0] == "download_configuration_async" assert home.mock_calls assert write_mock.mock_calls diff --git a/tests/components/homematicip_cloud/test_light.py b/tests/components/homematicip_cloud/test_light.py index c0717e81e0d..48d9beccacc 100644 --- a/tests/components/homematicip_cloud/test_light.py +++ b/tests/components/homematicip_cloud/test_light.py @@ -54,7 +54,7 @@ async def test_hmip_light( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) @@ -68,7 +68,7 @@ async def test_hmip_light( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) @@ -104,7 +104,7 @@ async def test_hmip_notification_light( {"entity_id": entity_id, "brightness_pct": "100", "transition": 100}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time" + assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time_async" assert hmip_device.mock_calls[-1][2] == { "channelIndex": 2, "rgb": "RED", @@ -130,7 +130,7 @@ async def test_hmip_notification_light( {"entity_id": entity_id, "hs_color": hs_color}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time" + assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time_async" assert hmip_device.mock_calls[-1][2] == { "channelIndex": 2, "dimLevel": 0.0392156862745098, @@ -157,7 +157,7 @@ async def test_hmip_notification_light( "light", "turn_off", {"entity_id": entity_id, "transition": 100}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 11 - assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time" + assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time_async" assert hmip_device.mock_calls[-1][2] == { "channelIndex": 2, "dimLevel": 0.0, @@ -294,7 +294,7 @@ async def test_hmip_dimmer( await hass.services.async_call( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1, 1) await hass.services.async_call( @@ -304,7 +304,7 @@ async def test_hmip_dimmer( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1.0, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 1) ha_state = hass.states.get(entity_id) @@ -318,7 +318,7 @@ async def test_hmip_dimmer( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 0) ha_state = hass.states.get(entity_id) @@ -355,7 +355,7 @@ async def test_hmip_light_measuring( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) await async_manipulate_test_data(hass, hmip_device, "currentPowerConsumption", 50) @@ -369,7 +369,7 @@ async def test_hmip_light_measuring( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -400,7 +400,7 @@ async def test_hmip_wired_multi_dimmer( await hass.services.async_call( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1, 1) await hass.services.async_call( @@ -410,7 +410,7 @@ async def test_hmip_wired_multi_dimmer( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0.39215686274509803, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 1, channel=1) ha_state = hass.states.get(entity_id) @@ -424,7 +424,7 @@ async def test_hmip_wired_multi_dimmer( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 0, channel=1) ha_state = hass.states.get(entity_id) @@ -459,7 +459,7 @@ async def test_hmip_din_rail_dimmer_3_channel1( await hass.services.async_call( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1, 1) await hass.services.async_call( @@ -469,7 +469,7 @@ async def test_hmip_din_rail_dimmer_3_channel1( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0.39215686274509803, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 1, channel=1) ha_state = hass.states.get(entity_id) @@ -483,7 +483,7 @@ async def test_hmip_din_rail_dimmer_3_channel1( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 0, channel=1) ha_state = hass.states.get(entity_id) @@ -518,7 +518,7 @@ async def test_hmip_din_rail_dimmer_3_channel2( await hass.services.async_call( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1, 2) await hass.services.async_call( @@ -528,7 +528,7 @@ async def test_hmip_din_rail_dimmer_3_channel2( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0.39215686274509803, 2) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 1, channel=2) ha_state = hass.states.get(entity_id) @@ -542,7 +542,7 @@ async def test_hmip_din_rail_dimmer_3_channel2( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0, 2) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 0, channel=2) ha_state = hass.states.get(entity_id) @@ -577,7 +577,7 @@ async def test_hmip_din_rail_dimmer_3_channel3( await hass.services.async_call( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1, 3) await hass.services.async_call( @@ -587,7 +587,7 @@ async def test_hmip_din_rail_dimmer_3_channel3( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0.39215686274509803, 3) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 1, channel=3) ha_state = hass.states.get(entity_id) @@ -601,7 +601,7 @@ async def test_hmip_din_rail_dimmer_3_channel3( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0, 3) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 0, channel=3) ha_state = hass.states.get(entity_id) diff --git a/tests/components/homematicip_cloud/test_lock.py b/tests/components/homematicip_cloud/test_lock.py index cb8a0188639..dd581cce044 100644 --- a/tests/components/homematicip_cloud/test_lock.py +++ b/tests/components/homematicip_cloud/test_lock.py @@ -50,7 +50,7 @@ async def test_hmip_doorlockdrive( {"entity_id": entity_id}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][0] == "set_lock_state_async" assert hmip_device.mock_calls[-1][1] == (HomematicLockState.OPEN,) await hass.services.async_call( @@ -59,7 +59,7 @@ async def test_hmip_doorlockdrive( {"entity_id": entity_id}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][0] == "set_lock_state_async" assert hmip_device.mock_calls[-1][1] == (HomematicLockState.LOCKED,) await hass.services.async_call( @@ -69,7 +69,7 @@ async def test_hmip_doorlockdrive( blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][0] == "set_lock_state_async" assert hmip_device.mock_calls[-1][1] == (HomematicLockState.UNLOCKED,) await async_manipulate_test_data( @@ -96,7 +96,7 @@ async def test_hmip_doorlockdrive_handle_errors( test_devices=[entity_name] ) with patch( - "homematicip.aio.device.AsyncDoorLockDrive.set_lock_state", + "homematicip.device.DoorLockDrive.set_lock_state_async", return_value={ "errorCode": "INVALID_NUMBER_PARAMETER_VALUE", "minValue": 0.0, diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index 2dda3116032..eebee050d51 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -720,3 +720,42 @@ async def test_hmip_esi_led_energy_counter_usage_high_tariff( ) assert ha_state.state == "23825.748" + + +async def test_hmip_absolute_humidity_sensor( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test absolute humidity sensor (vaporAmount).""" + entity_id = "sensor.elvshctv_absolute_humidity" + entity_name = "elvshctv Absolute Humidity" + device_model = "ELV-SH-CTH" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["elvshctv"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "6098" + + +async def test_hmip_absolute_humidity_sensor_invalid_value( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test absolute humidity sensor with invalid value for vaporAmount.""" + entity_id = "sensor.elvshctv_absolute_humidity" + entity_name = "elvshctv Absolute Humidity" + device_model = "ELV-SH-CTH" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["elvshctv"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + await async_manipulate_test_data(hass, hmip_device, "vaporAmount", None, 1) + ha_state = hass.states.get(entity_id) + + assert ha_state.state == STATE_UNKNOWN diff --git a/tests/components/homematicip_cloud/test_switch.py b/tests/components/homematicip_cloud/test_switch.py index 54cdd632d03..bd7952025bc 100644 --- a/tests/components/homematicip_cloud/test_switch.py +++ b/tests/components/homematicip_cloud/test_switch.py @@ -42,7 +42,7 @@ async def test_hmip_switch( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -52,7 +52,7 @@ async def test_hmip_switch( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) @@ -81,7 +81,7 @@ async def test_hmip_switch_input( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -91,7 +91,7 @@ async def test_hmip_switch_input( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) @@ -120,7 +120,7 @@ async def test_hmip_switch_measuring( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -130,7 +130,7 @@ async def test_hmip_switch_measuring( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", True) await async_manipulate_test_data(hass, hmip_device, "currentPowerConsumption", 50) @@ -158,7 +158,7 @@ async def test_hmip_group_switch( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -168,7 +168,7 @@ async def test_hmip_group_switch( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) @@ -208,7 +208,7 @@ async def test_hmip_multi_switch( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) @@ -218,7 +218,7 @@ async def test_hmip_multi_switch( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -259,7 +259,7 @@ async def test_hmip_wired_multi_switch( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -269,7 +269,7 @@ async def test_hmip_wired_multi_switch( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index e31e630807e..8bf2e66a286 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -18,7 +18,6 @@ from homeassistant.auth.models import User from homeassistant.auth.providers import trusted_networks from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import websocket_api -from homeassistant.components.http import KEY_HASS from homeassistant.components.http.auth import ( CONTENT_USER_NAME, DATA_SIGN_SECRET, @@ -28,13 +27,13 @@ from homeassistant.components.http.auth import ( async_sign_path, async_user_not_allowed_do_auth, ) -from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components.http.forwarded import async_setup_forwarded from homeassistant.components.http.request_context import ( current_request, setup_request_context, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS from homeassistant.setup import async_setup_component from . import HTTP_HEADER_HA_AUTH diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 59011de0cfd..51d3e4ed992 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -11,7 +11,6 @@ from aiohttp.web_middlewares import middleware import pytest from homeassistant.components import http -from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS from homeassistant.components.http.ban import ( IP_BANS_FILE, KEY_BAN_MANAGER, @@ -22,6 +21,7 @@ from homeassistant.components.http.ban import ( from homeassistant.components.http.view import request_handler_factory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS from homeassistant.setup import async_setup_component from tests.common import async_get_persistent_notifications diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index c0256abb25d..0581c7bac2a 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -1,6 +1,5 @@ """Test cors for the HTTP component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus from pathlib import Path from unittest.mock import patch @@ -18,9 +17,8 @@ from aiohttp.test_utils import TestClient import pytest from homeassistant.components.http.cors import setup_cors -from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant -from homeassistant.helpers.http import KEY_ALLOW_CONFIGURED_CORS +from homeassistant.helpers.http import KEY_ALLOW_CONFIGURED_CORS, HomeAssistantView from homeassistant.setup import async_setup_component from . import HTTP_HEADER_HA_AUTH @@ -56,14 +54,12 @@ async def mock_handler(request): @pytest.fixture -def client( - event_loop: AbstractEventLoop, aiohttp_client: ClientSessionGenerator -) -> TestClient: +async def client(aiohttp_client: ClientSessionGenerator) -> TestClient: """Fixture to set up a web.Application.""" app = web.Application() setup_cors(app, [TRUSTED_ORIGIN]) app[KEY_ALLOW_CONFIGURED_CORS](app.router.add_get("/", mock_handler)) - return event_loop.run_until_complete(aiohttp_client(app)) + return await aiohttp_client(app) async def test_cors_requests(client) -> None: diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 4d96f2267fa..2937e673946 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -22,7 +22,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.ssl import server_context_intermediate, server_context_modern -from tests.common import async_fire_time_changed +from tests.common import async_call_logger_set_level, async_fire_time_changed from tests.typing import ClientSessionGenerator @@ -505,27 +505,21 @@ async def test_logging( ) ) hass.states.async_set("logging.entity", "hello") - await hass.services.async_call( - "logger", - "set_level", - {"aiohttp.access": "info"}, - blocking=True, - ) - client = await hass_client() - response = await client.get("/api/states/logging.entity") - assert response.status == HTTPStatus.OK + async with async_call_logger_set_level( + "aiohttp.access", "INFO", hass=hass, caplog=caplog + ): + client = await hass_client() + response = await client.get("/api/states/logging.entity") + assert response.status == HTTPStatus.OK - assert "GET /api/states/logging.entity" in caplog.text - caplog.clear() - await hass.services.async_call( - "logger", - "set_level", - {"aiohttp.access": "warning"}, - blocking=True, - ) - response = await client.get("/api/states/logging.entity") - assert response.status == HTTPStatus.OK - assert "GET /api/states/logging.entity" not in caplog.text + assert "GET /api/states/logging.entity" in caplog.text + caplog.clear() + async with async_call_logger_set_level( + "aiohttp.access", "WARNING", hass=hass, caplog=caplog + ): + response = await client.get("/api/states/logging.entity") + assert response.status == HTTPStatus.OK + assert "GET /api/states/logging.entity" not in caplog.text async def test_register_static_paths( diff --git a/tests/components/husqvarna_automower/conftest.py b/tests/components/husqvarna_automower/conftest.py index 49994e4f3ae..871f108bfd0 100644 --- a/tests/components/husqvarna_automower/conftest.py +++ b/tests/components/husqvarna_automower/conftest.py @@ -6,7 +6,7 @@ import time from unittest.mock import AsyncMock, patch from aioautomower.model import MowerAttributes -from aioautomower.session import AutomowerSession, _MowerCommands +from aioautomower.session import AutomowerSession, MowerCommands from aioautomower.utils import mower_list_to_dictionary_dataclass from aiohttp import ClientWebSocketResponse import pytest @@ -119,7 +119,7 @@ def mock_automower_client(values) -> Generator[AsyncMock]: mock = AsyncMock(spec=AutomowerSession) mock.auth = AsyncMock(side_effect=ClientWebSocketResponse) - mock.commands = AsyncMock(spec_set=_MowerCommands) + mock.commands = AsyncMock(spec_set=MowerCommands) mock.get_status.return_value = values mock.start_listening = AsyncMock(side_effect=listen) @@ -142,7 +142,7 @@ def mock_automower_client_one_mower(values) -> Generator[AsyncMock]: mock = AsyncMock(spec=AutomowerSession) mock.auth = AsyncMock(side_effect=ClientWebSocketResponse) - mock.commands = AsyncMock(spec_set=_MowerCommands) + mock.commands = AsyncMock(spec_set=MowerCommands) mock.get_status.return_value = values mock.start_listening = AsyncMock(side_effect=listen) diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index 92320de6fdb..979d40a53d8 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -65,7 +65,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.test_mower_1_cutting_blade_usage_time', 'has_entity_name': True, 'hidden_by': None, @@ -120,7 +120,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.test_mower_1_downtime', 'has_entity_name': True, 'hidden_by': None, @@ -171,7 +171,6 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'no_error', 'alarm_mower_in_motion', 'alarm_mower_lifted', 'alarm_mower_stopped', @@ -180,13 +179,11 @@ 'alarm_outside_geofence', 'angular_sensor_problem', 'battery_problem', - 'battery_problem', 'battery_restriction_due_to_ambient_temperature', 'can_error', 'charging_current_too_high', 'charging_station_blocked', 'charging_system_problem', - 'charging_system_problem', 'collision_sensor_defect', 'collision_sensor_error', 'collision_sensor_problem_front', @@ -197,24 +194,18 @@ 'connection_changed', 'connection_not_changed', 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', 'connectivity_settings_restored', 'cutting_drive_motor_1_defect', 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', - 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', + 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', - 'cutting_system_blocked', 'cutting_system_imbalance_warning', 'cutting_system_major_imbalance', 'destination_not_reachable', @@ -222,13 +213,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', - 'error', - 'error_at_power_up', - 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', - 'geofence_problem', 'gps_navigation_problem', 'guide_1_not_found', 'guide_2_not_found', @@ -246,7 +233,6 @@ 'lift_sensor_defect', 'lifted', 'limited_cutting_height_range', - 'limited_cutting_height_range', 'loop_sensor_defect', 'loop_sensor_problem_front', 'loop_sensor_problem_left', @@ -259,6 +245,7 @@ 'no_accurate_position_from_satellites', 'no_confirmed_position', 'no_drive', + 'no_error', 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', @@ -269,9 +256,6 @@ 'safety_function_faulty', 'settings_restored', 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', 'sim_card_not_found', 'sim_card_requires_pin', 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', @@ -281,13 +265,6 @@ 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', 'tilt_sensor_problem', 'too_high_discharge_current', 'too_high_internal_current', @@ -317,6 +294,13 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', + 'error_at_power_up', + 'error', + 'fatal_error', + 'off', + 'stopped', + 'wait_power_up', + 'wait_updating', ]), }), 'config_entry_id': , @@ -353,7 +337,6 @@ 'device_class': 'enum', 'friendly_name': 'Test Mower 1 Error', 'options': list([ - 'no_error', 'alarm_mower_in_motion', 'alarm_mower_lifted', 'alarm_mower_stopped', @@ -362,13 +345,11 @@ 'alarm_outside_geofence', 'angular_sensor_problem', 'battery_problem', - 'battery_problem', 'battery_restriction_due_to_ambient_temperature', 'can_error', 'charging_current_too_high', 'charging_station_blocked', 'charging_system_problem', - 'charging_system_problem', 'collision_sensor_defect', 'collision_sensor_error', 'collision_sensor_problem_front', @@ -379,24 +360,18 @@ 'connection_changed', 'connection_not_changed', 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', 'connectivity_settings_restored', 'cutting_drive_motor_1_defect', 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', - 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', + 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', - 'cutting_system_blocked', 'cutting_system_imbalance_warning', 'cutting_system_major_imbalance', 'destination_not_reachable', @@ -404,13 +379,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', - 'error', - 'error_at_power_up', - 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', - 'geofence_problem', 'gps_navigation_problem', 'guide_1_not_found', 'guide_2_not_found', @@ -428,7 +399,6 @@ 'lift_sensor_defect', 'lifted', 'limited_cutting_height_range', - 'limited_cutting_height_range', 'loop_sensor_defect', 'loop_sensor_problem_front', 'loop_sensor_problem_left', @@ -441,6 +411,7 @@ 'no_accurate_position_from_satellites', 'no_confirmed_position', 'no_drive', + 'no_error', 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', @@ -451,9 +422,6 @@ 'safety_function_faulty', 'settings_restored', 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', 'sim_card_not_found', 'sim_card_requires_pin', 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', @@ -463,13 +431,6 @@ 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', 'tilt_sensor_problem', 'too_high_discharge_current', 'too_high_internal_current', @@ -499,6 +460,13 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', + 'error_at_power_up', + 'error', + 'fatal_error', + 'off', + 'stopped', + 'wait_power_up', + 'wait_updating', ]), }), 'context': , @@ -1280,7 +1248,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.test_mower_1_uptime', 'has_entity_name': True, 'hidden_by': None, @@ -1449,7 +1417,6 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'no_error', 'alarm_mower_in_motion', 'alarm_mower_lifted', 'alarm_mower_stopped', @@ -1458,13 +1425,11 @@ 'alarm_outside_geofence', 'angular_sensor_problem', 'battery_problem', - 'battery_problem', 'battery_restriction_due_to_ambient_temperature', 'can_error', 'charging_current_too_high', 'charging_station_blocked', 'charging_system_problem', - 'charging_system_problem', 'collision_sensor_defect', 'collision_sensor_error', 'collision_sensor_problem_front', @@ -1475,24 +1440,18 @@ 'connection_changed', 'connection_not_changed', 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', 'connectivity_settings_restored', 'cutting_drive_motor_1_defect', 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', - 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', + 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', - 'cutting_system_blocked', 'cutting_system_imbalance_warning', 'cutting_system_major_imbalance', 'destination_not_reachable', @@ -1500,13 +1459,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', - 'error', - 'error_at_power_up', - 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', - 'geofence_problem', 'gps_navigation_problem', 'guide_1_not_found', 'guide_2_not_found', @@ -1524,7 +1479,6 @@ 'lift_sensor_defect', 'lifted', 'limited_cutting_height_range', - 'limited_cutting_height_range', 'loop_sensor_defect', 'loop_sensor_problem_front', 'loop_sensor_problem_left', @@ -1537,6 +1491,7 @@ 'no_accurate_position_from_satellites', 'no_confirmed_position', 'no_drive', + 'no_error', 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', @@ -1547,9 +1502,6 @@ 'safety_function_faulty', 'settings_restored', 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', 'sim_card_not_found', 'sim_card_requires_pin', 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', @@ -1559,13 +1511,6 @@ 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', 'tilt_sensor_problem', 'too_high_discharge_current', 'too_high_internal_current', @@ -1595,6 +1540,13 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', + 'error_at_power_up', + 'error', + 'fatal_error', + 'off', + 'stopped', + 'wait_power_up', + 'wait_updating', ]), }), 'config_entry_id': , @@ -1631,7 +1583,6 @@ 'device_class': 'enum', 'friendly_name': 'Test Mower 2 Error', 'options': list([ - 'no_error', 'alarm_mower_in_motion', 'alarm_mower_lifted', 'alarm_mower_stopped', @@ -1640,13 +1591,11 @@ 'alarm_outside_geofence', 'angular_sensor_problem', 'battery_problem', - 'battery_problem', 'battery_restriction_due_to_ambient_temperature', 'can_error', 'charging_current_too_high', 'charging_station_blocked', 'charging_system_problem', - 'charging_system_problem', 'collision_sensor_defect', 'collision_sensor_error', 'collision_sensor_problem_front', @@ -1657,24 +1606,18 @@ 'connection_changed', 'connection_not_changed', 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', 'connectivity_settings_restored', 'cutting_drive_motor_1_defect', 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', - 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', + 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', - 'cutting_system_blocked', 'cutting_system_imbalance_warning', 'cutting_system_major_imbalance', 'destination_not_reachable', @@ -1682,13 +1625,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', - 'error', - 'error_at_power_up', - 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', - 'geofence_problem', 'gps_navigation_problem', 'guide_1_not_found', 'guide_2_not_found', @@ -1706,7 +1645,6 @@ 'lift_sensor_defect', 'lifted', 'limited_cutting_height_range', - 'limited_cutting_height_range', 'loop_sensor_defect', 'loop_sensor_problem_front', 'loop_sensor_problem_left', @@ -1719,6 +1657,7 @@ 'no_accurate_position_from_satellites', 'no_confirmed_position', 'no_drive', + 'no_error', 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', @@ -1729,9 +1668,6 @@ 'safety_function_faulty', 'settings_restored', 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', 'sim_card_not_found', 'sim_card_requires_pin', 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', @@ -1741,13 +1677,6 @@ 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', 'tilt_sensor_problem', 'too_high_discharge_current', 'too_high_internal_current', @@ -1777,6 +1706,13 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', + 'error_at_power_up', + 'error', + 'fatal_error', + 'off', + 'stopped', + 'wait_power_up', + 'wait_updating', ]), }), 'context': , diff --git a/tests/components/husqvarna_automower/test_lawn_mower.py b/tests/components/husqvarna_automower/test_lawn_mower.py index 044989e5cf0..12c53d709ca 100644 --- a/tests/components/husqvarna_automower/test_lawn_mower.py +++ b/tests/components/husqvarna_automower/test_lawn_mower.py @@ -21,37 +21,47 @@ from .const import TEST_MOWER_ID from tests.common import MockConfigEntry, async_fire_time_changed -async def test_lawn_mower_states( - hass: HomeAssistant, - mock_automower_client: AsyncMock, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, - values: dict[str, MowerAttributes], -) -> None: - """Test lawn_mower state.""" - await setup_integration(hass, mock_config_entry) - state = hass.states.get("lawn_mower.test_mower_1") - assert state is not None - assert state.state == LawnMowerActivity.DOCKED - - for activity, state, expected_state in ( +@pytest.mark.parametrize( + ("activity", "mower_state", "expected_state"), + [ (MowerActivities.UNKNOWN, MowerStates.PAUSED, LawnMowerActivity.PAUSED), - (MowerActivities.MOWING, MowerStates.NOT_APPLICABLE, LawnMowerActivity.MOWING), + (MowerActivities.MOWING, MowerStates.IN_OPERATION, LawnMowerActivity.MOWING), (MowerActivities.NOT_APPLICABLE, MowerStates.ERROR, LawnMowerActivity.ERROR), ( MowerActivities.GOING_HOME, MowerStates.IN_OPERATION, LawnMowerActivity.RETURNING, ), - ): - values[TEST_MOWER_ID].mower.activity = activity - values[TEST_MOWER_ID].mower.state = state - mock_automower_client.get_status.return_value = values - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - state = hass.states.get("lawn_mower.test_mower_1") - assert state.state == expected_state + ( + MowerActivities.NOT_APPLICABLE, + MowerStates.IN_OPERATION, + LawnMowerActivity.MOWING, + ), + ], +) +async def test_lawn_mower_states( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + values: dict[str, MowerAttributes], + activity: MowerActivities, + mower_state: MowerStates, + expected_state: LawnMowerActivity, +) -> None: + """Test lawn_mower state.""" + await setup_integration(hass, mock_config_entry) + state = hass.states.get("lawn_mower.test_mower_1") + assert state is not None + assert state.state == LawnMowerActivity.DOCKED + values[TEST_MOWER_ID].mower.activity = activity + values[TEST_MOWER_ID].mower.state = mower_state + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("lawn_mower.test_mower_1") + assert state.state == expected_state @pytest.mark.parametrize( diff --git a/tests/components/husqvarna_automower/test_number.py b/tests/components/husqvarna_automower/test_number.py index 814846ae1c6..628011e3f15 100644 --- a/tests/components/husqvarna_automower/test_number.py +++ b/tests/components/husqvarna_automower/test_number.py @@ -68,7 +68,7 @@ async def test_number_workarea_commands( values[TEST_MOWER_ID].work_areas[123456].cutting_height = 75 mock_automower_client.get_status.return_value = values mocked_method = AsyncMock() - setattr(mock_automower_client.commands, "workarea_settings", mocked_method) + mock_automower_client.commands.workarea_settings.return_value = mocked_method await hass.services.async_call( domain="number", service="set_value", @@ -79,12 +79,12 @@ async def test_number_workarea_commands( freezer.tick(timedelta(seconds=EXECUTION_TIME_DELAY)) async_fire_time_changed(hass) await hass.async_block_till_done() - mocked_method.assert_called_once_with(TEST_MOWER_ID, 123456, cutting_height=75) + mocked_method.cutting_height.assert_called_once_with(cutting_height=75) state = hass.states.get(entity_id) assert state.state is not None assert state.state == "75" - mocked_method.side_effect = ApiError("Test error") + mocked_method.cutting_height.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/husqvarna_automower/test_switch.py b/tests/components/husqvarna_automower/test_switch.py index 48903a9630b..00b04ce9903 100644 --- a/tests/components/husqvarna_automower/test_switch.py +++ b/tests/components/husqvarna_automower/test_switch.py @@ -192,7 +192,7 @@ async def test_work_area_switch_commands( values[TEST_MOWER_ID].work_areas[TEST_AREA_ID].enabled = boolean mock_automower_client.get_status.return_value = values mocked_method = AsyncMock() - setattr(mock_automower_client.commands, "workarea_settings", mocked_method) + mock_automower_client.commands.workarea_settings.return_value = mocked_method await hass.services.async_call( domain=SWITCH_DOMAIN, service=service, @@ -202,12 +202,12 @@ async def test_work_area_switch_commands( freezer.tick(timedelta(seconds=EXECUTION_TIME_DELAY)) async_fire_time_changed(hass) await hass.async_block_till_done() - mocked_method.assert_called_once_with(TEST_MOWER_ID, TEST_AREA_ID, enabled=boolean) + mocked_method.enabled.assert_called_once_with(enabled=boolean) state = hass.states.get(entity_id) assert state is not None assert state.state == excepted_state - mocked_method.side_effect = ApiError("Test error") + mocked_method.enabled.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/imeon_inverter/__init__.py b/tests/components/imeon_inverter/__init__.py new file mode 100644 index 00000000000..8305be2d901 --- /dev/null +++ b/tests/components/imeon_inverter/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the Imeon Inverter integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the Imeon Inverter integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/imeon_inverter/conftest.py b/tests/components/imeon_inverter/conftest.py new file mode 100644 index 00000000000..38fb0d90322 --- /dev/null +++ b/tests/components/imeon_inverter/conftest.py @@ -0,0 +1,85 @@ +"""Configuration for the Imeon Inverter integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant.components.imeon_inverter.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, 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_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) + +from tests.common import MockConfigEntry, load_json_object_fixture, patch + +# Sample test data +TEST_USER_INPUT = { + CONF_HOST: "192.168.200.1", + CONF_USERNAME: "user@local", + CONF_PASSWORD: "password", +} + +TEST_SERIAL = "111111111111111" + +TEST_DISCOVER = SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=f"http://{TEST_USER_INPUT[CONF_HOST]}:8088/imeon.xml", + upnp={ + ATTR_UPNP_MANUFACTURER: "IMEON", + ATTR_UPNP_MODEL_NAME: "IMEON", + ATTR_UPNP_FRIENDLY_NAME: f"IMEON-{TEST_SERIAL}", + ATTR_UPNP_SERIAL: TEST_SERIAL, + ATTR_UPNP_UDN: "uuid:01234567-89ab-cdef-0123-456789abcdef", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:Basic:1", + }, +) + + +@pytest.fixture(autouse=True) +def mock_imeon_inverter() -> Generator[MagicMock]: + """Mock data from the device.""" + with ( + patch( + "homeassistant.components.imeon_inverter.coordinator.Inverter", + autospec=True, + ) as inverter_mock, + patch( + "homeassistant.components.imeon_inverter.config_flow.Inverter", + new=inverter_mock, + ), + ): + inverter = inverter_mock.return_value + inverter.__aenter__.return_value = inverter + inverter.login.return_value = True + inverter.get_serial.return_value = TEST_SERIAL + inverter.storage = load_json_object_fixture("sensor_data.json", DOMAIN) + yield inverter + + +@pytest.fixture +def mock_async_setup_entry() -> Generator[AsyncMock]: + """Fixture for mocking async_setup_entry.""" + with patch( + "homeassistant.components.imeon_inverter.async_setup_entry", + return_value=True, + ) as mock: + yield mock + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + title="Imeon inverter", + domain=DOMAIN, + data=TEST_USER_INPUT, + unique_id=TEST_SERIAL, + ) diff --git a/tests/components/imeon_inverter/fixtures/sensor_data.json b/tests/components/imeon_inverter/fixtures/sensor_data.json new file mode 100644 index 00000000000..566716fe3fa --- /dev/null +++ b/tests/components/imeon_inverter/fixtures/sensor_data.json @@ -0,0 +1,73 @@ +{ + "battery": { + "autonomy": 4.5, + "charge_time": 120, + "power": 2500.0, + "soc": 78.0, + "stored": 10.2 + }, + "grid": { + "current_l1": 12.5, + "current_l2": 10.8, + "current_l3": 11.2, + "frequency": 50.0, + "voltage_l1": 230.0, + "voltage_l2": 229.5, + "voltage_l3": 230.1 + }, + "input": { + "power_l1": 1000.0, + "power_l2": 950.0, + "power_l3": 980.0, + "power_total": 2930.0 + }, + "inverter": { + "charging_current_limit": 50, + "injection_power_limit": 5000.0 + }, + "meter": { + "power": 2000.0, + "power_protocol": 2018.0 + }, + "output": { + "current_l1": 15.0, + "current_l2": 14.5, + "current_l3": 15.2, + "frequency": 49.9, + "power_l1": 1100.0, + "power_l2": 1080.0, + "power_l3": 1120.0, + "power_total": 3300.0, + "voltage_l1": 231.0, + "voltage_l2": 229.8, + "voltage_l3": 230.2 + }, + "pv": { + "consumed": 1500.0, + "injected": 800.0, + "power_1": 1200.0, + "power_2": 1300.0, + "power_total": 2500.0 + }, + "temp": { + "air_temperature": 25.0, + "component_temperature": 45.5 + }, + "monitoring": { + "building_consumption": 3000.0, + "economy_factor": 0.8, + "grid_consumption": 500.0, + "grid_injection": 700.0, + "grid_power_flow": -200.0, + "self_consumption": 85.0, + "self_sufficiency": 90.0, + "solar_production": 2600.0 + }, + "monitoring_minute": { + "building_consumption": 50.0, + "grid_consumption": 8.3, + "grid_injection": 11.7, + "grid_power_flow": -3.4, + "solar_production": 43.3 + } +} diff --git a/tests/components/imeon_inverter/snapshots/test_sensor.ambr b/tests/components/imeon_inverter/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..38f50df5407 --- /dev/null +++ b/tests/components/imeon_inverter/snapshots/test_sensor.ambr @@ -0,0 +1,2689 @@ +# serializer version: 1 +# name: test_sensors[sensor.imeon_inverter_air_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_air_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': 'Air temperature', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temp_air_temperature', + 'unique_id': '111111111111111_temp_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Imeon inverter Air temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_air_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_battery_autonomy', + '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 autonomy', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': '111111111111111_battery_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Imeon inverter Battery autonomy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_battery_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.5', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_charge_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_battery_charge_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery charge time', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_charge_time', + 'unique_id': '111111111111111_battery_charge_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_charge_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Imeon inverter Battery charge time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_battery_charge_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_battery_power', + '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 power', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_power', + 'unique_id': '111111111111111_battery_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Battery power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_battery_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2500.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_state_of_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_battery_state_of_charge', + '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 state of charge', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_soc', + 'unique_id': '111111111111111_battery_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_state_of_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Imeon inverter Battery state of charge', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_battery_state_of_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '78.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_stored-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_battery_stored', + '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 stored', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_stored', + 'unique_id': '111111111111111_battery_stored', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_stored-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Imeon inverter Battery stored', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_battery_stored', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.2', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_charging_current_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_charging_current_limit', + '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 current limit', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'inverter_charging_current_limit', + 'unique_id': '111111111111111_inverter_charging_current_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_charging_current_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Charging current limit', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_charging_current_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_component_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_component_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': 'Component temperature', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temp_component_temperature', + 'unique_id': '111111111111111_temp_component_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_component_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Imeon inverter Component temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_component_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.5', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_current_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid current L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_current_l1', + 'unique_id': '111111111111111_grid_current_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Grid current L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.5', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_current_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid current L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_current_l2', + 'unique_id': '111111111111111_grid_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Grid current L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.8', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_current_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid current L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_current_l3', + 'unique_id': '111111111111111_grid_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Grid current L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.2', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid frequency', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_frequency', + 'unique_id': '111111111111111_grid_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Imeon inverter Grid frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid voltage L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_voltage_l1', + 'unique_id': '111111111111111_grid_voltage_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Grid voltage L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid voltage L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_voltage_l2', + 'unique_id': '111111111111111_grid_voltage_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Grid voltage L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '229.5', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid voltage L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_voltage_l3', + 'unique_id': '111111111111111_grid_voltage_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Grid voltage L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.1', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_injection_power_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_injection_power_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Injection power limit', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'inverter_injection_power_limit', + 'unique_id': '111111111111111_inverter_injection_power_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_injection_power_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Injection power limit', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_injection_power_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5000.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_input_power_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input power L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'input_power_l1', + 'unique_id': '111111111111111_input_power_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Input power L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_input_power_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_input_power_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input power L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'input_power_l2', + 'unique_id': '111111111111111_input_power_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Input power L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_input_power_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '950.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_input_power_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input power L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'input_power_l3', + 'unique_id': '111111111111111_input_power_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Input power L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_input_power_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '980.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_input_power_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input power total', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'input_power_total', + 'unique_id': '111111111111111_input_power_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Input power total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_input_power_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2930.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_meter_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_meter_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meter power', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_power', + 'unique_id': '111111111111111_meter_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_meter_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Meter power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_meter_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2000.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_meter_power_protocol-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_meter_power_protocol', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meter power protocol', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_power_protocol', + 'unique_id': '111111111111111_meter_power_protocol', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_meter_power_protocol-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Meter power protocol', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_meter_power_protocol', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2018.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption', + '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': 'Monitoring building consumption', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_building_consumption', + 'unique_id': '111111111111111_monitoring_building_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Monitoring building consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3000.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption_minute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption_minute', + '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': 'Monitoring building consumption (minute)', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_building_consumption', + 'unique_id': '111111111111111_monitoring_minute_building_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption_minute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Monitoring building consumption (minute)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption_minute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_economy_factor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_economy_factor', + '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': None, + 'original_icon': None, + 'original_name': 'Monitoring economy factor', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_economy_factor', + 'unique_id': '111111111111111_monitoring_economy_factor', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_economy_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Imeon inverter Monitoring economy factor', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_economy_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.8', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption', + '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': 'Monitoring grid consumption', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_grid_consumption', + 'unique_id': '111111111111111_monitoring_grid_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Monitoring grid consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '500.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption_minute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption_minute', + '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': 'Monitoring grid consumption (minute)', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_grid_consumption', + 'unique_id': '111111111111111_monitoring_minute_grid_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption_minute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Monitoring grid consumption (minute)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption_minute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.3', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection', + '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': 'Monitoring grid injection', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_grid_injection', + 'unique_id': '111111111111111_monitoring_grid_injection', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Monitoring grid injection', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '700.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection_minute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection_minute', + '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': 'Monitoring grid injection (minute)', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_grid_injection', + 'unique_id': '111111111111111_monitoring_minute_grid_injection', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection_minute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Monitoring grid injection (minute)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection_minute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.7', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow', + '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': 'Monitoring grid power flow', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_grid_power_flow', + 'unique_id': '111111111111111_monitoring_grid_power_flow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Monitoring grid power flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-200.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow_minute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow_minute', + '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': 'Monitoring grid power flow (minute)', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_grid_power_flow', + 'unique_id': '111111111111111_monitoring_minute_grid_power_flow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow_minute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Monitoring grid power flow (minute)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow_minute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-3.4', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_self_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_self_consumption', + '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': None, + 'original_icon': None, + 'original_name': 'Monitoring self-consumption', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_self_consumption', + 'unique_id': '111111111111111_monitoring_self_consumption', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_self_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Imeon inverter Monitoring self-consumption', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_self_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '85.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_self_sufficiency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_self_sufficiency', + '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': None, + 'original_icon': None, + 'original_name': 'Monitoring self-sufficiency', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_self_sufficiency', + 'unique_id': '111111111111111_monitoring_self_sufficiency', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_self_sufficiency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Imeon inverter Monitoring self-sufficiency', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_self_sufficiency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production', + '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': 'Monitoring solar production', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_solar_production', + 'unique_id': '111111111111111_monitoring_solar_production', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Monitoring solar production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2600.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production_minute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production_minute', + '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': 'Monitoring solar production (minute)', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_solar_production', + 'unique_id': '111111111111111_monitoring_minute_solar_production', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production_minute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Monitoring solar production (minute)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production_minute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.3', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_current_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output current L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_current_l1', + 'unique_id': '111111111111111_output_current_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Output current L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_current_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output current L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_current_l2', + 'unique_id': '111111111111111_output_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Output current L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.5', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_current_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output current L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_current_l3', + 'unique_id': '111111111111111_output_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Output current L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.2', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output frequency', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_frequency', + 'unique_id': '111111111111111_output_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Imeon inverter Output frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_power_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output power L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_power_l1', + 'unique_id': '111111111111111_output_power_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Output power L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_power_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1100.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_power_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output power L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_power_l2', + 'unique_id': '111111111111111_output_power_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Output power L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_power_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1080.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_power_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output power L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_power_l3', + 'unique_id': '111111111111111_output_power_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Output power L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_power_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1120.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_power_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output power total', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_power_total', + 'unique_id': '111111111111111_output_power_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Output power total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_power_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3300.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output voltage L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_voltage_l1', + 'unique_id': '111111111111111_output_voltage_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Output voltage L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output voltage L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_voltage_l2', + 'unique_id': '111111111111111_output_voltage_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Output voltage L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '229.8', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output voltage L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_voltage_l3', + 'unique_id': '111111111111111_output_voltage_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Output voltage L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.2', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_pv_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PV consumed', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pv_consumed', + 'unique_id': '111111111111111_pv_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter PV consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_pv_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1500.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_injected-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_pv_injected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PV injected', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pv_injected', + 'unique_id': '111111111111111_pv_injected', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_injected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter PV injected', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_pv_injected', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '800.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_pv_power_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': 'PV power 1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pv_power_1', + 'unique_id': '111111111111111_pv_power_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter PV power 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_pv_power_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1200.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_pv_power_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': 'PV power 2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pv_power_2', + 'unique_id': '111111111111111_pv_power_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter PV power 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_pv_power_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1300.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_pv_power_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PV power total', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pv_power_total', + 'unique_id': '111111111111111_pv_power_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter PV power total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_pv_power_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2500.0', + }) +# --- diff --git a/tests/components/imeon_inverter/test_config_flow.py b/tests/components/imeon_inverter/test_config_flow.py new file mode 100644 index 00000000000..9ebcf3ec80f --- /dev/null +++ b/tests/components/imeon_inverter/test_config_flow.py @@ -0,0 +1,205 @@ +"""Test the Imeon Inverter config flow.""" + +from copy import deepcopy +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant.components.imeon_inverter.const import DOMAIN +from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER +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_SERIAL + +from .conftest import TEST_DISCOVER, TEST_SERIAL, TEST_USER_INPUT + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_async_setup_entry") + + +async def test_form_valid( + hass: HomeAssistant, + mock_async_setup_entry: AsyncMock, +) -> None: + """Test we get the form and the config is created with the good entries.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Imeon {TEST_SERIAL}" + assert result["data"] == TEST_USER_INPUT + assert result["result"].unique_id == TEST_SERIAL + assert mock_async_setup_entry.call_count == 1 + + +async def test_form_invalid_auth( + hass: HomeAssistant, mock_imeon_inverter: MagicMock +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + mock_imeon_inverter.login.return_value = False + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + mock_imeon_inverter.login.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + (TimeoutError, "cannot_connect"), + (ValueError("Host invalid"), "invalid_host"), + (ValueError("Route invalid"), "invalid_route"), + (ValueError, "unknown"), + ], +) +async def test_form_exception( + hass: HomeAssistant, + mock_imeon_inverter: MagicMock, + error: Exception, + expected: str, +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + mock_imeon_inverter.login.side_effect = error + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected} + + mock_imeon_inverter.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_manual_setup_already_exists( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a flow with an existing id aborts.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_get_serial_timeout( + hass: HomeAssistant, mock_imeon_inverter: MagicMock +) -> None: + """Test the timeout error handling of getting the serial number.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + mock_imeon_inverter.get_serial.side_effect = TimeoutError + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_imeon_inverter.get_serial.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_ssdp(hass: HomeAssistant) -> None: + """Test a ssdp discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data=TEST_DISCOVER, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + user_input = TEST_USER_INPUT.copy() + user_input.pop(CONF_HOST) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Imeon {TEST_SERIAL}" + assert result["data"] == TEST_USER_INPUT + + +async def test_ssdp_already_exist( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a ssdp discovery flow with an existing id aborts.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data=TEST_DISCOVER, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_ssdp_abort(hass: HomeAssistant) -> None: + """Test that a ssdp discovery aborts if serial is unknown.""" + data = deepcopy(TEST_DISCOVER) + data.upnp.pop(ATTR_UPNP_SERIAL, None) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data=data, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/imeon_inverter/test_sensor.py b/tests/components/imeon_inverter/test_sensor.py new file mode 100644 index 00000000000..19e912c1c5c --- /dev/null +++ b/tests/components/imeon_inverter/test_sensor.py @@ -0,0 +1,29 @@ +"""Test the Imeon Inverter sensors.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + mock_imeon_inverter: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Imeon Inverter sensors.""" + with patch( + "homeassistant.components.imeon_inverter.const.PLATFORMS", [Platform.SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/incomfort/snapshots/test_climate.ambr b/tests/components/incomfort/snapshots/test_climate.ambr index df3fe3f710b..d435bac81eb 100644 --- a/tests/components/incomfort/snapshots/test_climate.ambr +++ b/tests/components/incomfort/snapshots/test_climate.ambr @@ -17,7 +17,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': , + 'entity_category': None, 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -84,7 +84,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': , + 'entity_category': None, 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -151,7 +151,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': , + 'entity_category': None, 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -218,7 +218,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': , + 'entity_category': None, 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/inkbird/__init__.py b/tests/components/inkbird/__init__.py index 01ae0bf8efc..f798fee292c 100644 --- a/tests/components/inkbird/__init__.py +++ b/tests/components/inkbird/__init__.py @@ -1,8 +1,44 @@ """Tests for the INKBIRD integration.""" -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from uuid import UUID -NOT_INKBIRD_SERVICE_INFO = BluetoothServiceInfo( +from bleak.backends.device import BLEDevice + +from homeassistant.components.bluetooth import MONOTONIC_TIME, BluetoothServiceInfoBleak + + +def _make_bluetooth_service_info( + name: str, + manufacturer_data: dict[int, bytes], + service_uuids: list[str], + address: str, + rssi: int, + service_data: dict[UUID, bytes], + source: str, + tx_power: int = 0, +) -> BluetoothServiceInfoBleak: + return BluetoothServiceInfoBleak( + name=name, + manufacturer_data=manufacturer_data, + service_uuids=service_uuids, + address=address, + rssi=rssi, + service_data=service_data, + source=source, + device=BLEDevice( + name=name, + address=address, + details={}, + rssi=rssi, + ), + time=MONOTONIC_TIME(), + advertisement=None, + connectable=True, + tx_power=tx_power, + ) + + +NOT_INKBIRD_SERVICE_INFO = _make_bluetooth_service_info( name="Not it", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -12,7 +48,7 @@ NOT_INKBIRD_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -SPS_SERVICE_INFO = BluetoothServiceInfo( +SPS_SERVICE_INFO = _make_bluetooth_service_info( name="sps", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -22,7 +58,19 @@ SPS_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -SPS_WITH_CORRUPT_NAME_SERVICE_INFO = BluetoothServiceInfo( + +SPS_PASSIVE_SERVICE_INFO = _make_bluetooth_service_info( + name="sps", + address="AA:BB:CC:DD:EE:FF", + rssi=-63, + service_data={}, + manufacturer_data={}, + service_uuids=["0000fff0-0000-1000-8000-00805f9b34fb"], + source="local", +) + + +SPS_WITH_CORRUPT_NAME_SERVICE_INFO = _make_bluetooth_service_info( name="XXXXcorruptXXXX", address="AA:BB:CC:DD:EE:FF", rssi=-63, @@ -33,7 +81,7 @@ SPS_WITH_CORRUPT_NAME_SERVICE_INFO = BluetoothServiceInfo( ) -IBBQ_SERVICE_INFO = BluetoothServiceInfo( +IBBQ_SERVICE_INFO = _make_bluetooth_service_info( name="iBBQ", address="4125DDBA-2774-4851-9889-6AADDD4CAC3D", rssi=-56, @@ -44,3 +92,14 @@ IBBQ_SERVICE_INFO = BluetoothServiceInfo( service_data={}, source="local", ) + + +IAM_T1_SERVICE_INFO = _make_bluetooth_service_info( + name="Ink@IAM-T1", + manufacturer_data={12628: b"AC-6200a13cae\x00\x00"}, + service_uuids=["0000fff0-0000-1000-8000-00805f9b34fb"], + address="62:00:A1:3C:AE:7B", + rssi=-44, + service_data={}, + source="local", +) diff --git a/tests/components/inkbird/test_config_flow.py b/tests/components/inkbird/test_config_flow.py index 796f57da55b..419bc742479 100644 --- a/tests/components/inkbird/test_config_flow.py +++ b/tests/components/inkbird/test_config_flow.py @@ -3,7 +3,7 @@ from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components.inkbird.const import DOMAIN +from homeassistant.components.inkbird.const import CONF_DEVICE_TYPE, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -27,7 +27,7 @@ async def test_async_step_bluetooth_valid_device(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "iBBQ AC3D" - assert result2["data"] == {} + assert result2["data"] == {CONF_DEVICE_TYPE: "iBBQ-4"} assert result2["result"].unique_id == "4125DDBA-2774-4851-9889-6AADDD4CAC3D" @@ -71,7 +71,7 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "IBS-TH 8105" - assert result2["data"] == {} + assert result2["data"] == {CONF_DEVICE_TYPE: "IBS-TH"} assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" @@ -101,7 +101,7 @@ async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "IBS-TH 8105" - assert result2["data"] == {} + assert result2["data"] == {CONF_DEVICE_TYPE: "IBS-TH"} assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" @@ -220,7 +220,7 @@ async def test_async_step_user_takes_precedence_over_discovery( ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "IBS-TH 8105" - assert result2["data"] == {} + assert result2["data"] == {CONF_DEVICE_TYPE: "IBS-TH"} assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" # Verify the original one was aborted diff --git a/tests/components/inkbird/test_sensor.py b/tests/components/inkbird/test_sensor.py index 0f3d6497c2b..1feb5f5b02c 100644 --- a/tests/components/inkbird/test_sensor.py +++ b/tests/components/inkbird/test_sensor.py @@ -1,16 +1,73 @@ """Test the INKBIRD config flow.""" -from homeassistant.components.inkbird.const import CONF_DEVICE_TYPE, DOMAIN +from collections.abc import Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from inkbird_ble import ( + DeviceKey, + INKBIRDBluetoothDeviceData, + SensorDescription, + SensorDeviceInfo, + SensorUpdate, + SensorValue, + Units, +) +from inkbird_ble.parser import Model +from sensor_state_data import SensorDeviceClass + +from homeassistant.components.inkbird.const import ( + CONF_DEVICE_DATA, + CONF_DEVICE_TYPE, + DOMAIN, +) +from homeassistant.components.inkbird.coordinator import FALLBACK_POLL_INTERVAL from homeassistant.components.sensor import ATTR_STATE_CLASS +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from . import SPS_SERVICE_INFO, SPS_WITH_CORRUPT_NAME_SERVICE_INFO +from . import ( + IAM_T1_SERVICE_INFO, + SPS_PASSIVE_SERVICE_INFO, + SPS_SERVICE_INFO, + SPS_WITH_CORRUPT_NAME_SERVICE_INFO, +) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import inject_bluetooth_service_info +def _make_sensor_update(name: str, humidity: float) -> SensorUpdate: + return SensorUpdate( + title=None, + devices={ + None: SensorDeviceInfo( + name=f"{name} EEFF", + model=name, + manufacturer="INKBIRD", + sw_version=None, + hw_version=None, + ) + }, + entity_descriptions={ + DeviceKey(key="humidity", device_id=None): SensorDescription( + device_key=DeviceKey(key="humidity", device_id=None), + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=Units.PERCENTAGE, + ), + }, + entity_values={ + DeviceKey(key="humidity", device_id=None): SensorValue( + device_key=DeviceKey(key="humidity", device_id=None), + name="Humidity", + native_value=humidity, + ), + }, + ) + + async def test_sensors(hass: HomeAssistant) -> None: """Test setting up creates the sensors.""" entry = MockConfigEntry( @@ -68,3 +125,134 @@ async def test_device_with_corrupt_name(hass: HomeAssistant) -> None: assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH" assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_polling_sensor(hass: HomeAssistant) -> None: + """Test setting up a device that needs polling.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="AA:BB:CC:DD:EE:FF", + data={CONF_DEVICE_TYPE: "IBS-TH"}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + with patch( + "homeassistant.components.inkbird.coordinator.INKBIRDBluetoothDeviceData.async_poll", + return_value=_make_sensor_update("IBS-TH", 10.24), + ): + inject_bluetooth_service_info(hass, SPS_PASSIVE_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + temp_sensor = hass.states.get("sensor.ibs_th_eeff_humidity") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "10.24" + assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "IBS-TH EEFF Humidity" + assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + + assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH" + + with patch( + "homeassistant.components.inkbird.coordinator.INKBIRDBluetoothDeviceData.async_poll", + return_value=_make_sensor_update("IBS-TH", 20.24), + ): + async_fire_time_changed(hass, dt_util.utcnow() + FALLBACK_POLL_INTERVAL) + inject_bluetooth_service_info(hass, SPS_PASSIVE_SERVICE_INFO) + await hass.async_block_till_done() + + temp_sensor = hass.states.get("sensor.ibs_th_eeff_humidity") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "20.24" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_notify_sensor_no_advertisement(hass: HomeAssistant) -> None: + """Test setting up a notify sensor that has no advertisement.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="62:00:A1:3C:AE:7B", + data={CONF_DEVICE_TYPE: "IAM-T1"}, + ) + entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_notify_sensor(hass: HomeAssistant) -> None: + """Test setting up a notify sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="62:00:A1:3C:AE:7B", + data={CONF_DEVICE_TYPE: "IAM-T1"}, + ) + entry.add_to_hass(hass) + inject_bluetooth_service_info(hass, IAM_T1_SERVICE_INFO) + saved_update_callback = None + saved_device_data_changed_callback = None + + class MockINKBIRDBluetoothDeviceData(INKBIRDBluetoothDeviceData): + def __init__( + self, + device_type: Model | str | None = None, + device_data: dict[str, Any] | None = None, + update_callback: Callable[[SensorUpdate], None] | None = None, + device_data_changed_callback: Callable[[dict[str, Any]], None] + | None = None, + ) -> None: + nonlocal saved_update_callback + nonlocal saved_device_data_changed_callback + saved_update_callback = update_callback + saved_device_data_changed_callback = device_data_changed_callback + super().__init__( + device_type=device_type, + device_data=device_data, + update_callback=update_callback, + device_data_changed_callback=device_data_changed_callback, + ) + + mock_client = MagicMock(start_notify=AsyncMock(), disconnect=AsyncMock()) + with ( + patch( + "homeassistant.components.inkbird.coordinator.INKBIRDBluetoothDeviceData", + MockINKBIRDBluetoothDeviceData, + ), + patch("inkbird_ble.parser.establish_connection", return_value=mock_client), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert len(hass.states.async_all()) == 0 + + saved_update_callback(_make_sensor_update("IAM-T1", 10.24)) + + assert len(hass.states.async_all()) == 1 + + temp_sensor = hass.states.get("sensor.iam_t1_eeff_humidity") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "10.24" + assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "IAM-T1 EEFF Humidity" + assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + + assert entry.data[CONF_DEVICE_TYPE] == "IAM-T1" + + saved_device_data_changed_callback({"temp_unit": "F"}) + assert entry.data[CONF_DEVICE_DATA] == {"temp_unit": "F"} + + saved_device_data_changed_callback({"temp_unit": "C"}) + assert entry.data[CONF_DEVICE_DATA] == {"temp_unit": "C"} + + saved_device_data_changed_callback({"temp_unit": "C"}) + assert entry.data[CONF_DEVICE_DATA] == {"temp_unit": "C"} diff --git a/tests/components/integration/test_config_flow.py b/tests/components/integration/test_config_flow.py index f8387d85174..37b0760dc03 100644 --- a/tests/components/integration/test_config_flow.py +++ b/tests/components/integration/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import selector -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("platform", ["sensor"]) @@ -67,17 +67,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: assert config_entry.title == "My integration" -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - @pytest.mark.parametrize("platform", ["sensor"]) async def test_options(hass: HomeAssistant, platform) -> None: """Test reconfiguring.""" @@ -108,7 +97,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "round") == 1.0 + assert get_schema_suggested_value(schema, "round") == 1.0 source = schema["source"] assert isinstance(source, selector.EntitySelector) diff --git a/tests/components/intellifire/snapshots/test_binary_sensor.ambr b/tests/components/intellifire/snapshots/test_binary_sensor.ambr index afa3c1fa8a9..c2ed8ff17b0 100644 --- a/tests/components/intellifire/snapshots/test_binary_sensor.ambr +++ b/tests/components/intellifire/snapshots/test_binary_sensor.ambr @@ -366,7 +366,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Flame Error', + 'original_name': 'Flame error', 'platform': 'intellifire', 'previous_unique_id': None, 'supported_features': 0, @@ -380,7 +380,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by unpublished Intellifire API', 'device_class': 'problem', - 'friendly_name': 'IntelliFire Flame Error', + 'friendly_name': 'IntelliFire Flame error', }), 'context': , 'entity_id': 'binary_sensor.intellifire_flame_error', diff --git a/tests/components/intellifire/snapshots/test_sensor.ambr b/tests/components/intellifire/snapshots/test_sensor.ambr index 548c8d5a8aa..3826b75a417 100644 --- a/tests/components/intellifire/snapshots/test_sensor.ambr +++ b/tests/components/intellifire/snapshots/test_sensor.ambr @@ -171,7 +171,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Fan Speed', + 'original_name': 'Fan speed', 'platform': 'intellifire', 'previous_unique_id': None, 'supported_features': 0, @@ -184,7 +184,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by unpublished Intellifire API', - 'friendly_name': 'IntelliFire Fan Speed', + 'friendly_name': 'IntelliFire Fan speed', 'state_class': , }), 'context': , diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index 0db9682d0ad..3779930e360 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -2,8 +2,10 @@ import pytest -from homeassistant.components.cover import SERVICE_OPEN_COVER -from homeassistant.components.lock import SERVICE_LOCK +from homeassistant.components.button import SERVICE_PRESS +from homeassistant.components.cover import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER +from homeassistant.components.lock import SERVICE_LOCK, SERVICE_UNLOCK +from homeassistant.components.valve import SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -121,41 +123,130 @@ async def test_turn_on_intent(hass: HomeAssistant) -> None: assert call.data == {"entity_id": ["light.test_light"]} -async def test_translated_turn_on_intent( +@pytest.mark.parametrize("domain", ["button", "input_button"]) +async def test_turn_on_intent_button( + hass: HomeAssistant, entity_registry: er.EntityRegistry, domain +) -> None: + """Test HassTurnOn intent on button domains.""" + assert await async_setup_component(hass, "intent", {}) + + button = entity_registry.async_get_or_create(domain, "test", "button_uid") + + hass.states.async_set(button.entity_id, "unknown") + button_service_calls = async_mock_service(hass, domain, SERVICE_PRESS) + + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, "test", "HassTurnOff", {"name": {"value": button.entity_id}} + ) + + await intent.async_handle( + hass, "test", "HassTurnOn", {"name": {"value": button.entity_id}} + ) + + assert len(button_service_calls) == 1 + call = button_service_calls[0] + assert call.domain == domain + assert call.service == SERVICE_PRESS + assert call.data == {"entity_id": button.entity_id} + + +async def test_turn_on_off_intent_valve( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """Test HassTurnOn intent on domains which don't have the intent.""" - result = await async_setup_component(hass, "homeassistant", {}) - result = await async_setup_component(hass, "intent", {}) - await hass.async_block_till_done() - assert result + """Test HassTurnOn/Off intent on valve domains.""" + assert await async_setup_component(hass, "intent", {}) + + valve = entity_registry.async_get_or_create("valve", "test", "valve_uid") + + hass.states.async_set(valve.entity_id, "closed") + open_calls = async_mock_service(hass, "valve", SERVICE_OPEN_VALVE) + close_calls = async_mock_service(hass, "valve", SERVICE_CLOSE_VALVE) + + await intent.async_handle( + hass, "test", "HassTurnOn", {"name": {"value": valve.entity_id}} + ) + + assert len(open_calls) == 1 + call = open_calls[0] + assert call.domain == "valve" + assert call.service == SERVICE_OPEN_VALVE + assert call.data == {"entity_id": valve.entity_id} + + await intent.async_handle( + hass, "test", "HassTurnOff", {"name": {"value": valve.entity_id}} + ) + + assert len(close_calls) == 1 + call = close_calls[0] + assert call.domain == "valve" + assert call.service == SERVICE_CLOSE_VALVE + assert call.data == {"entity_id": valve.entity_id} + + +async def test_turn_on_off_intent_cover( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test HassTurnOn/Off intent on cover domains.""" + assert await async_setup_component(hass, "intent", {}) cover = entity_registry.async_get_or_create("cover", "test", "cover_uid") - lock = entity_registry.async_get_or_create("lock", "test", "lock_uid") hass.states.async_set(cover.entity_id, "closed") - hass.states.async_set(lock.entity_id, "unlocked") - cover_service_calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER) - lock_service_calls = async_mock_service(hass, "lock", SERVICE_LOCK) + open_calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER) + close_calls = async_mock_service(hass, "cover", SERVICE_CLOSE_COVER) await intent.async_handle( hass, "test", "HassTurnOn", {"name": {"value": cover.entity_id}} ) + + assert len(open_calls) == 1 + call = open_calls[0] + assert call.domain == "cover" + assert call.service == SERVICE_OPEN_COVER + assert call.data == {"entity_id": cover.entity_id} + + await intent.async_handle( + hass, "test", "HassTurnOff", {"name": {"value": cover.entity_id}} + ) + + assert len(close_calls) == 1 + call = close_calls[0] + assert call.domain == "cover" + assert call.service == SERVICE_CLOSE_COVER + assert call.data == {"entity_id": cover.entity_id} + + +async def test_turn_on_off_intent_lock( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test HassTurnOn/Off intent on lock domains.""" + assert await async_setup_component(hass, "intent", {}) + + lock = entity_registry.async_get_or_create("lock", "test", "lock_uid") + + hass.states.async_set(lock.entity_id, "locked") + unlock_calls = async_mock_service(hass, "lock", SERVICE_UNLOCK) + lock_calls = async_mock_service(hass, "lock", SERVICE_LOCK) + await intent.async_handle( hass, "test", "HassTurnOn", {"name": {"value": lock.entity_id}} ) - await hass.async_block_till_done() - assert len(cover_service_calls) == 1 - call = cover_service_calls[0] - assert call.domain == "cover" - assert call.service == "open_cover" - assert call.data == {"entity_id": cover.entity_id} - - assert len(lock_service_calls) == 1 - call = lock_service_calls[0] + assert len(lock_calls) == 1 + call = lock_calls[0] assert call.domain == "lock" - assert call.service == "lock" + assert call.service == SERVICE_LOCK + assert call.data == {"entity_id": lock.entity_id} + + await intent.async_handle( + hass, "test", "HassTurnOff", {"name": {"value": lock.entity_id}} + ) + + assert len(unlock_calls) == 1 + call = unlock_calls[0] + assert call.domain == "lock" + assert call.service == SERVICE_UNLOCK assert call.data == {"entity_id": lock.entity_id} diff --git a/tests/components/iron_os/conftest.py b/tests/components/iron_os/conftest.py index bf8c756ebee..479ee2fde7b 100644 --- a/tests/components/iron_os/conftest.py +++ b/tests/components/iron_os/conftest.py @@ -159,9 +159,10 @@ def mock_ironosupdate() -> Generator[AsyncMock]: @pytest.fixture def mock_pynecil() -> Generator[AsyncMock]: """Mock Pynecil library.""" - with patch( - "homeassistant.components.iron_os.Pynecil", autospec=True - ) as mock_client: + with ( + patch("homeassistant.components.iron_os.Pynecil", autospec=True) as mock_client, + patch("homeassistant.components.iron_os.config_flow.Pynecil", new=mock_client), + ): client = mock_client.return_value client.get_device_info.return_value = DeviceInfoResponse( @@ -170,6 +171,7 @@ def mock_pynecil() -> Generator[AsyncMock]: address="c0:ff:ee:c0:ff:ee", device_sn="0000c0ffeec0ffee", name=DEFAULT_NAME, + is_synced=True, ) client.get_settings.return_value = SettingsDataResponse( sleep_temp=150, @@ -225,4 +227,6 @@ def mock_pynecil() -> Generator[AsyncMock]: operating_mode=OperatingMode.SOLDERING, estimated_power=24.8, ) + client._client = AsyncMock() + client._client.return_value.is_connected = True yield client diff --git a/tests/components/iron_os/snapshots/test_diagnostics.ambr b/tests/components/iron_os/snapshots/test_diagnostics.ambr index 49cb3878b87..d377b531560 100644 --- a/tests/components/iron_os/snapshots/test_diagnostics.ambr +++ b/tests/components/iron_os/snapshots/test_diagnostics.ambr @@ -6,7 +6,7 @@ }), 'device_info': dict({ '__type': "", - 'repr': "DeviceInfoResponse(build='v2.23', device_id='c0ffeeC0', address='c0:ff:ee:c0:ff:ee', device_sn='0000c0ffeec0ffee', name='Pinecil-C0FFEEE', is_synced=False)", + 'repr': "DeviceInfoResponse(build='v2.23', device_id='c0ffeeC0', address='c0:ff:ee:c0:ff:ee', device_sn='0000c0ffeec0ffee', name='Pinecil-C0FFEEE', is_synced=True)", }), 'live_data': dict({ '__type': "", diff --git a/tests/components/iron_os/test_config_flow.py b/tests/components/iron_os/test_config_flow.py index 88bef117c26..ba3e7f4b230 100644 --- a/tests/components/iron_os/test_config_flow.py +++ b/tests/components/iron_os/test_config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock +from pynecil import CommunicationError import pytest from homeassistant.components.iron_os import DOMAIN @@ -16,7 +17,7 @@ from .conftest import DEFAULT_NAME, PINECIL_SERVICE_INFO, USER_INPUT from tests.common import MockConfigEntry -@pytest.mark.usefixtures("discovery") +@pytest.mark.usefixtures("discovery", "mock_pynecil") async def test_async_step_user( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: @@ -34,10 +35,52 @@ async def test_async_step_user( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == DEFAULT_NAME assert result["data"] == {} + assert result["result"].unique_id == "c0:ff:ee:c0:ff:ee" assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (CommunicationError, "cannot_connect"), + (Exception, "unknown"), + ], +) @pytest.mark.usefixtures("discovery") +async def test_async_step_user_errors( + hass: HomeAssistant, + mock_pynecil: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test the user config flow errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + mock_pynecil.connect.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pynecil.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"] == DEFAULT_NAME + assert result["data"] == {} + assert result["result"].unique_id == "c0:ff:ee:c0:ff:ee" + + +@pytest.mark.usefixtures("discovery", "mock_pynecil") async def test_async_step_user_device_added_between_steps( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: @@ -73,6 +116,7 @@ async def test_form_no_device_discovered( assert result["reason"] == "no_devices_found" +@pytest.mark.usefixtures("mock_pynecil") async def test_async_step_bluetooth(hass: HomeAssistant) -> None: """Test discovery via bluetooth.""" result = await hass.config_entries.flow.async_init( @@ -92,6 +136,49 @@ async def test_async_step_bluetooth(hass: HomeAssistant) -> None: assert result["result"].unique_id == "c0:ff:ee:c0:ff:ee" +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (CommunicationError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_async_step_bluetooth_errors( + hass: HomeAssistant, + mock_pynecil: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test discovery via bluetooth errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=PINECIL_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + mock_pynecil.connect.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pynecil.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"] == DEFAULT_NAME + assert result["data"] == {} + assert result["result"].unique_id == "c0:ff:ee:c0:ff:ee" + + +@pytest.mark.usefixtures("mock_pynecil") async def test_async_step_bluetooth_devices_already_setup( hass: HomeAssistant, config_entry: AsyncMock ) -> None: @@ -108,7 +195,7 @@ async def test_async_step_bluetooth_devices_already_setup( assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("discovery") +@pytest.mark.usefixtures("discovery", "mock_pynecil") async def test_async_step_user_setup_replaces_igonored_device( hass: HomeAssistant, config_entry_ignored: AsyncMock ) -> None: diff --git a/tests/components/iron_os/test_init.py b/tests/components/iron_os/test_init.py index d1c596f4de5..6adc0b778f0 100644 --- a/tests/components/iron_os/test_init.py +++ b/tests/components/iron_os/test_init.py @@ -10,6 +10,8 @@ import pytest from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH from .conftest import DEFAULT_NAME @@ -35,41 +37,6 @@ async def test_setup_and_unload( assert config_entry.state is ConfigEntryState.NOT_LOADED -@pytest.mark.usefixtures("ble_device") -async def test_update_data_config_entry_not_ready( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_pynecil: AsyncMock, -) -> None: - """Test config entry not ready.""" - mock_pynecil.get_live_data.side_effect = CommunicationError - 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.SETUP_RETRY - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") -async def test_setup_config_entry_not_ready( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_pynecil: AsyncMock, - freezer: FrozenDateTimeFactory, -) -> None: - """Test config entry not ready.""" - mock_pynecil.get_settings.side_effect = CommunicationError - mock_pynecil.get_device_info.side_effect = CommunicationError - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - freezer.tick(timedelta(seconds=3)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.SETUP_RETRY - - @pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") async def test_settings_exception( hass: HomeAssistant, @@ -123,3 +90,47 @@ async def test_v223_entities_not_loaded( ) is not None assert len(state.attributes["options"]) == 2 + + +@pytest.mark.usefixtures("ble_device") +async def test_device_info_update( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test device info gets updated.""" + + mock_pynecil.get_device_info.return_value = DeviceInfoResponse() + 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 + + device = device_registry.async_get_device( + connections={(CONNECTION_BLUETOOTH, config_entry.unique_id)} + ) + assert device + assert device.sw_version is None + assert device.serial_number is None + + mock_pynecil.get_device_info.return_value = DeviceInfoResponse( + build="v2.22", + device_id="c0ffeeC0", + address="c0:ff:ee:c0:ff:ee", + device_sn="0000c0ffeec0ffee", + name=DEFAULT_NAME, + ) + + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + connections={(CONNECTION_BLUETOOTH, config_entry.unique_id)} + ) + assert device + assert device.sw_version == "v2.22" + assert device.serial_number == "0000c0ffeec0ffee (ID:c0ffeeC0)" diff --git a/tests/components/iron_os/test_sensor.py b/tests/components/iron_os/test_sensor.py index fec111c5799..da77cb7958d 100644 --- a/tests/components/iron_os/test_sensor.py +++ b/tests/components/iron_os/test_sensor.py @@ -4,7 +4,7 @@ from collections.abc import AsyncGenerator from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory -from pynecil import CommunicationError, LiveDataResponse +from pynecil import LiveDataResponse import pytest from syrupy.assertion import SnapshotAssertion @@ -62,7 +62,7 @@ async def test_sensors_unavailable( assert config_entry.state is ConfigEntryState.LOADED - mock_pynecil.get_live_data.side_effect = CommunicationError + mock_pynecil.is_connected = False freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) diff --git a/tests/components/iron_os/test_update.py b/tests/components/iron_os/test_update.py index 47f3197da0e..137d42a5d51 100644 --- a/tests/components/iron_os/test_update.py +++ b/tests/components/iron_os/test_update.py @@ -3,16 +3,17 @@ from collections.abc import AsyncGenerator from unittest.mock import AsyncMock, patch -from pynecil import UpdateException +from pynecil import CommunicationError, UpdateException import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.update import ATTR_INSTALLED_VERSION from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, mock_restore_cache, snapshot_platform from tests.typing import WebSocketGenerator @@ -75,3 +76,34 @@ async def test_update_unavailable( state = hass.states.get("update.pinecil_firmware") assert state is not None assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("ble_device") +async def test_update_restore_last_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, +) -> None: + """Test update entity restore last state.""" + + mock_pynecil.get_device_info.side_effect = CommunicationError + mock_restore_cache( + hass, + ( + State( + "update.pinecil_firmware", + STATE_ON, + attributes={ATTR_INSTALLED_VERSION: "v2.21"}, + ), + ), + ) + 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 + + state = hass.states.get("update.pinecil_firmware") + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.21" diff --git a/tests/components/ista_ecotrend/conftest.py b/tests/components/ista_ecotrend/conftest.py index 7edf2e4717b..58977c99b59 100644 --- a/tests/components/ista_ecotrend/conftest.py +++ b/tests/components/ista_ecotrend/conftest.py @@ -80,7 +80,7 @@ def mock_ista() -> Generator[MagicMock]: "26e93f1a-c828-11ea-87d0-0242ac130003", "eaf5c5c8-889f-4a3c-b68c-e9a676505762", ] - client.get_consumption_data = get_consumption_data + client.get_consumption_data.side_effect = get_consumption_data yield client diff --git a/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr b/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c9f5e72ae1f --- /dev/null +++ b/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr @@ -0,0 +1,205 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + '26e93f1a-c828-11ea-87d0-0242ac130003': dict({ + 'consumptionUnitId': '26e93f1a-c828-11ea-87d0-0242ac130003', + 'consumptions': list([ + dict({ + 'date': dict({ + 'month': 5, + 'year': 2024, + }), + 'readings': list([ + dict({ + 'additionalValue': '38,0', + 'type': 'heating', + 'value': '35', + }), + dict({ + 'additionalValue': '57,0', + 'type': 'warmwater', + 'value': '1,0', + }), + dict({ + 'type': 'water', + 'value': '5,0', + }), + ]), + }), + dict({ + 'date': dict({ + 'month': 4, + 'year': 2024, + }), + 'readings': list([ + dict({ + 'additionalValue': '113,0', + 'type': 'heating', + 'value': '104', + }), + dict({ + 'additionalValue': '61,1', + 'type': 'warmwater', + 'value': '1,1', + }), + dict({ + 'type': 'water', + 'value': '6,8', + }), + ]), + }), + ]), + 'costs': list([ + dict({ + 'costsByEnergyType': list([ + dict({ + 'type': 'heating', + 'value': 21, + }), + dict({ + 'type': 'warmwater', + 'value': 7, + }), + dict({ + 'type': 'water', + 'value': 3, + }), + ]), + 'date': dict({ + 'month': 5, + 'year': 2024, + }), + }), + dict({ + 'costsByEnergyType': list([ + dict({ + 'type': 'heating', + 'value': 62, + }), + dict({ + 'type': 'warmwater', + 'value': 7, + }), + dict({ + 'type': 'water', + 'value': 2, + }), + ]), + 'date': dict({ + 'month': 4, + 'year': 2024, + }), + }), + ]), + }), + 'eaf5c5c8-889f-4a3c-b68c-e9a676505762': dict({ + 'consumptionUnitId': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762', + 'consumptions': list([ + dict({ + 'date': dict({ + 'month': 5, + 'year': 2024, + }), + 'readings': list([ + dict({ + 'additionalValue': '38,0', + 'type': 'heating', + 'value': '35', + }), + dict({ + 'additionalValue': '57,0', + 'type': 'warmwater', + 'value': '1,0', + }), + dict({ + 'type': 'water', + 'value': '5,0', + }), + ]), + }), + dict({ + 'date': dict({ + 'month': 4, + 'year': 2024, + }), + 'readings': list([ + dict({ + 'additionalValue': '113,0', + 'type': 'heating', + 'value': '104', + }), + dict({ + 'additionalValue': '61,1', + 'type': 'warmwater', + 'value': '1,1', + }), + dict({ + 'type': 'water', + 'value': '6,8', + }), + ]), + }), + ]), + 'costs': list([ + dict({ + 'costsByEnergyType': list([ + dict({ + 'type': 'heating', + 'value': 21, + }), + dict({ + 'type': 'warmwater', + 'value': 7, + }), + dict({ + 'type': 'water', + 'value': 3, + }), + ]), + 'date': dict({ + 'month': 5, + 'year': 2024, + }), + }), + dict({ + 'costsByEnergyType': list([ + dict({ + 'type': 'heating', + 'value': 62, + }), + dict({ + 'type': 'warmwater', + 'value': 7, + }), + dict({ + 'type': 'water', + 'value': 2, + }), + ]), + 'date': dict({ + 'month': 4, + 'year': 2024, + }), + }), + ]), + }), + }), + 'details': dict({ + '26e93f1a-c828-11ea-87d0-0242ac130003': dict({ + 'address': dict({ + 'houseNumber': '**REDACTED**', + 'street': '**REDACTED**', + }), + 'id': '26e93f1a-c828-11ea-87d0-0242ac130003', + }), + 'eaf5c5c8-889f-4a3c-b68c-e9a676505762': dict({ + 'address': dict({ + 'houseNumber': '**REDACTED**', + 'street': '**REDACTED**', + }), + 'id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762', + }), + }), + }) +# --- diff --git a/tests/components/ista_ecotrend/test_config_flow.py b/tests/components/ista_ecotrend/test_config_flow.py index d6c88c51c99..094ff17fb7f 100644 --- a/tests/components/ista_ecotrend/test_config_flow.py +++ b/tests/components/ista_ecotrend/test_config_flow.py @@ -11,6 +11,8 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + @pytest.mark.usefixtures("mock_ista") async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: @@ -47,14 +49,14 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: (IndexError, "unknown"), ], ) -async def test_form_invalid_auth( +async def test_form_error_and_recover( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_ista: MagicMock, side_effect: Exception, error_text: str, ) -> None: - """Test we handle invalid auth.""" + """Test config flow error and recover.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -89,10 +91,10 @@ async def test_form_invalid_auth( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_ista") async def test_reauth( hass: HomeAssistant, - ista_config_entry: AsyncMock, - mock_ista: MagicMock, + ista_config_entry: MockConfigEntry, ) -> None: """Test reauth flow.""" @@ -131,12 +133,12 @@ async def test_reauth( ) async def test_reauth_error_and_recover( hass: HomeAssistant, - ista_config_entry: AsyncMock, + ista_config_entry: MockConfigEntry, mock_ista: MagicMock, side_effect: Exception, error_text: str, ) -> None: - """Test reauth flow.""" + """Test reauth flow error and recover.""" ista_config_entry.add_to_hass(hass) @@ -174,3 +176,186 @@ async def test_reauth_error_and_recover( CONF_PASSWORD: "new-password", } assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_ista") +async def test_form_already_configured( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, +) -> None: + """Test we abort form login when entry is already configured.""" + + ista_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_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_ista") +async def test_flow_reauth_unique_id_mismatch(hass: HomeAssistant) -> None: + """Test reauth flow unique id mismatch.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + unique_id="42243134-21f6-40a2-a79f-e417a3a12104", + ) + + 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"], + { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + }, + ) + + 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("mock_ista") +async def test_reconfigure( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + + ista_config_entry.add_to_hass(hass) + + result = await ista_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"], + { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + }, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert ista_config_entry.data == { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_text"), + [ + (LoginError(None), "invalid_auth"), + (ServerError, "cannot_connect"), + (IndexError, "unknown"), + ], +) +async def test_reconfigure_error_and_recover( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, + mock_ista: MagicMock, + side_effect: Exception, + error_text: str, +) -> None: + """Test reconfigure flow error and recover.""" + + ista_config_entry.add_to_hass(hass) + + result = await ista_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_ista.login.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_text} + + mock_ista.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + }, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert ista_config_entry.data == { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_ista") +async def test_flow_reconfigure_unique_id_mismatch(hass: HomeAssistant) -> None: + """Test reconfigure flow unique id mismatch.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + unique_id="42243134-21f6-40a2-a79f-e417a3a12104", + ) + + 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"], + { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + }, + ) + + 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 diff --git a/tests/components/ista_ecotrend/test_diagnostics.py b/tests/components/ista_ecotrend/test_diagnostics.py new file mode 100644 index 00000000000..83e28b0b7f8 --- /dev/null +++ b/tests/components/ista_ecotrend/test_diagnostics.py @@ -0,0 +1,27 @@ +"""Tests for ista EcoTrend diagnostics platform .""" + +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("mock_ista") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ista_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, ista_config_entry) + == snapshot + ) diff --git a/tests/components/ista_ecotrend/test_init.py b/tests/components/ista_ecotrend/test_init.py index a15e4577252..b73232a7d74 100644 --- a/tests/components/ista_ecotrend/test_init.py +++ b/tests/components/ista_ecotrend/test_init.py @@ -1,11 +1,12 @@ """Test the ista EcoTrend init.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from pyecotrend_ista import KeycloakError, LoginError, ParserError, ServerError import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.ista_ecotrend.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -60,7 +61,7 @@ async def test_config_entry_auth_failed( mock_ista: MagicMock, side_effect: Exception, ) -> None: - """Test config entry not ready.""" + """Test config entry auth failed.""" mock_ista.login.side_effect = side_effect ista_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(ista_config_entry.entry_id) @@ -88,3 +89,49 @@ async def test_device_registry( device_registry, ista_config_entry.entry_id ): assert device == snapshot + + +async def test_update_failed( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, + mock_ista: MagicMock, +) -> None: + """Test coordinator update failed.""" + + with patch( + "homeassistant.components.ista_ecotrend.PLATFORMS", + [], + ): + mock_ista.get_consumption_data.side_effect = ServerError + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_auth_failed( + hass: HomeAssistant, ista_config_entry: MockConfigEntry, mock_ista: MagicMock +) -> None: + """Test coordinator auth failed and reauth flow started.""" + with patch( + "homeassistant.components.ista_ecotrend.PLATFORMS", + [], + ): + mock_ista.get_consumption_data.side_effect = LoginError + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == ista_config_entry.entry_id diff --git a/tests/components/ista_ecotrend/test_statistics.py b/tests/components/ista_ecotrend/test_statistics.py index aa4f71037c4..b5f419437c5 100644 --- a/tests/components/ista_ecotrend/test_statistics.py +++ b/tests/components/ista_ecotrend/test_statistics.py @@ -84,3 +84,61 @@ async def test_statistics_import( assert stats[statistic_id] == snapshot(name=f"{statistic_id}_3months") assert len(stats[statistic_id]) == 3 + + +@pytest.mark.usefixtures("recorder_mock", "mock_ista") +async def test_remove( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, +) -> None: + """Test remove config entry and clear statistics.""" + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.LOADED + await async_wait_recording_done(hass) + + assert await hass.async_add_executor_job( + statistics_during_period, + hass, + datetime.datetime.fromtimestamp(0, tz=datetime.UTC), + None, + {"ista_ecotrend:bahnhofsstr_1a_heating"}, + "month", + None, + {"state", "sum"}, + ) + + assert await hass.config_entries.async_unload(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.NOT_LOADED + await async_wait_recording_done(hass) + + assert await hass.async_add_executor_job( + statistics_during_period, + hass, + datetime.datetime.fromtimestamp(0, tz=datetime.UTC), + None, + {"ista_ecotrend:bahnhofsstr_1a_heating"}, + "month", + None, + {"state", "sum"}, + ) + + assert await hass.config_entries.async_remove(ista_config_entry.entry_id) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + + assert not await hass.async_add_executor_job( + statistics_during_period, + hass, + datetime.datetime.fromtimestamp(0, tz=datetime.UTC), + None, + {"ista_ecotrend:bahnhofsstr_1a_heating"}, + "month", + None, + {"state", "sum"}, + ) diff --git a/tests/components/jellyfin/snapshots/test_media_source.ambr b/tests/components/jellyfin/snapshots/test_media_source.ambr index 6f46aaf3f9b..12398f16b8f 100644 --- a/tests/components/jellyfin/snapshots/test_media_source.ambr +++ b/tests/components/jellyfin/snapshots/test_media_source.ambr @@ -15,6 +15,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children': None, 'children_media_class': None, 'domain': 'jellyfin', @@ -31,6 +32,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': None, 'children_media_class': None, 'domain': 'jellyfin', @@ -47,6 +49,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': None, 'children_media_class': None, 'domain': 'jellyfin', @@ -63,6 +66,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children': None, 'children_media_class': None, 'domain': 'jellyfin', @@ -85,6 +89,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': None, 'children_media_class': None, 'domain': 'jellyfin', @@ -101,6 +106,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': None, 'children_media_class': None, 'domain': 'jellyfin', @@ -117,6 +123,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': None, 'children_media_class': None, 'domain': 'jellyfin', @@ -133,6 +140,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children': None, 'children_media_class': None, 'domain': 'jellyfin', diff --git a/tests/components/jellyfin/test_media_player.py b/tests/components/jellyfin/test_media_player.py index c6f015e9bb4..404fdc801ee 100644 --- a/tests/components/jellyfin/test_media_player.py +++ b/tests/components/jellyfin/test_media_player.py @@ -279,6 +279,7 @@ async def test_browse_media( "media_content_id": "COLLECTION-FOLDER-UUID", "can_play": False, "can_expand": True, + "can_search": False, "thumbnail": "http://localhost/Items/c22fd826-17fc-44f4-9b04-1eb3e8fb9173/Images/Backdrop.jpg", "children_media_class": None, } @@ -307,6 +308,7 @@ async def test_browse_media( "media_content_id": "EPISODE-UUID", "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": "http://localhost/Items/c22fd826-17fc-44f4-9b04-1eb3e8fb9173/Images/Backdrop.jpg", "children_media_class": None, } diff --git a/tests/components/jewish_calendar/__init__.py b/tests/components/jewish_calendar/__init__.py index dc66c1e0d7d..d6928c189e8 100644 --- a/tests/components/jewish_calendar/__init__.py +++ b/tests/components/jewish_calendar/__init__.py @@ -1,57 +1 @@ """Tests for the jewish_calendar component.""" - -from collections import namedtuple -from datetime import datetime - -from homeassistant.components import jewish_calendar -from homeassistant.util import dt as dt_util - -_LatLng = namedtuple("_LatLng", ["lat", "lng"]) # noqa: PYI024 - -HDATE_DEFAULT_ALTITUDE = 754 -NYC_LATLNG = _LatLng(40.7128, -74.0060) -JERUSALEM_LATLNG = _LatLng(31.778, 35.235) - - -def make_nyc_test_params(dtime, results, havdalah_offset=0): - """Make test params for NYC.""" - if isinstance(results, dict): - time_zone = dt_util.get_time_zone("America/New_York") - results = { - key: value.replace(tzinfo=time_zone) - if isinstance(value, datetime) - else value - for key, value in results.items() - } - return ( - dtime, - jewish_calendar.DEFAULT_CANDLE_LIGHT, - havdalah_offset, - True, - "America/New_York", - NYC_LATLNG.lat, - NYC_LATLNG.lng, - results, - ) - - -def make_jerusalem_test_params(dtime, results, havdalah_offset=0): - """Make test params for Jerusalem.""" - if isinstance(results, dict): - time_zone = dt_util.get_time_zone("Asia/Jerusalem") - results = { - key: value.replace(tzinfo=time_zone) - if isinstance(value, datetime) - else value - for key, value in results.items() - } - return ( - dtime, - 40, - havdalah_offset, - False, - "Asia/Jerusalem", - JERUSALEM_LATLNG.lat, - JERUSALEM_LATLNG.lng, - results, - ) diff --git a/tests/components/jewish_calendar/conftest.py b/tests/components/jewish_calendar/conftest.py index 97909291f27..5cd7ad34085 100644 --- a/tests/components/jewish_calendar/conftest.py +++ b/tests/components/jewish_calendar/conftest.py @@ -1,22 +1,40 @@ """Common fixtures for the jewish_calendar tests.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator, Iterable +import datetime as dt +from typing import NamedTuple from unittest.mock import AsyncMock, patch +from freezegun import freeze_time +from hdate.translator import set_language import pytest -from homeassistant.components.jewish_calendar.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.jewish_calendar.const import ( + CONF_CANDLE_LIGHT_MINUTES, + CONF_DIASPORA, + CONF_HAVDALAH_OFFSET_MINUTES, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.const import CONF_LANGUAGE, CONF_TIME_ZONE +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry -@pytest.fixture -def mock_config_entry() -> MockConfigEntry: - """Return the default mocked config entry.""" - return MockConfigEntry( - title=DEFAULT_NAME, - domain=DOMAIN, - ) +class _LocationData(NamedTuple): + timezone: str + diaspora: bool + lat: float + lng: float + candle_lighting: int + + +LOCATIONS = { + "Jerusalem": _LocationData("Asia/Jerusalem", False, 31.7683, 35.2137, 40), + "New York": _LocationData("America/New_York", True, 40.7128, -74.006, 18), +} @pytest.fixture @@ -26,3 +44,120 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.jewish_calendar.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def location_data(request: pytest.FixtureRequest) -> _LocationData | None: + """Return data based on location name.""" + if not hasattr(request, "param"): + return None + + return LOCATIONS[request.param] + + +@pytest.fixture +def tz_info(hass: HomeAssistant, location_data: _LocationData | None) -> dt.tzinfo: + """Return time zone info.""" + if location_data is None: + return dt_util.get_time_zone(hass.config.time_zone) + return dt_util.get_time_zone(location_data.timezone) + + +@pytest.fixture(name="test_time") +def _test_time( + request: pytest.FixtureRequest, tz_info: dt.tzinfo +) -> dt.datetime | None: + """Return localized test time based.""" + if not hasattr(request, "param"): + return None + + return request.param.replace(tzinfo=tz_info) + + +@pytest.fixture +def results( + request: pytest.FixtureRequest, tz_info: dt.tzinfo, language: str +) -> Iterable: + """Return localized results.""" + if not hasattr(request, "param"): + return None + + # If results are generated, by using the HDate library, we need to set the language + set_language(language) + + if isinstance(request.param, dict): + result = { + key: value.replace(tzinfo=tz_info) + if isinstance(value, dt.datetime) + else value + for key, value in request.param.items() + } + if "attr" in result and isinstance(result["attr"], dict): + result["attr"] = { + key: value() if callable(value) else value + for key, value in result["attr"].items() + } + return result + return request.param + + +@pytest.fixture +def havdalah_offset() -> int | None: + """Return None if default havdalah offset is not specified.""" + return None + + +@pytest.fixture +def language() -> str: + """Return default language value, unless language is parametrized.""" + return "en" + + +@pytest.fixture(autouse=True) +async def setup_hass(hass: HomeAssistant, location_data: _LocationData | None) -> None: + """Set up Home Assistant for testing the jewish_calendar integration.""" + + if location_data: + await hass.config.async_set_time_zone(location_data.timezone) + hass.config.latitude = location_data.lat + hass.config.longitude = location_data.lng + + +@pytest.fixture +def config_entry( + location_data: _LocationData | None, + language: str, + havdalah_offset: int | None, +) -> MockConfigEntry: + """Set up the jewish_calendar integration for testing.""" + param_data = {} + param_options = {} + + if location_data: + param_data = { + CONF_DIASPORA: location_data.diaspora, + CONF_TIME_ZONE: location_data.timezone, + } + param_options[CONF_CANDLE_LIGHT_MINUTES] = location_data.candle_lighting + + if havdalah_offset: + param_options[CONF_HAVDALAH_OFFSET_MINUTES] = havdalah_offset + + return MockConfigEntry( + title=DEFAULT_NAME, + domain=DOMAIN, + data={CONF_LANGUAGE: language, **param_data}, + options=param_options, + ) + + +@pytest.fixture +async def setup_at_time( + test_time: dt.datetime, hass: HomeAssistant, config_entry: MockConfigEntry +) -> AsyncGenerator[None]: + """Set up the jewish_calendar integration at a specific time.""" + with freeze_time(test_time): + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + yield diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index 194e6fe9d01..46f5fdfcc7d 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -1,301 +1,145 @@ """The tests for the Jewish calendar binary sensors.""" from datetime import datetime as dt, timedelta -import logging +from typing import Any -from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.jewish_calendar.const import ( - CONF_CANDLE_LIGHT_MINUTES, - CONF_DIASPORA, - CONF_HAVDALAH_OFFSET_MINUTES, - DEFAULT_NAME, - DOMAIN, -) -from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.components.jewish_calendar.const import DOMAIN +from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util - -from . import make_jerusalem_test_params, make_nyc_test_params - -from tests.common import MockConfigEntry, async_fire_time_changed - -_LOGGER = logging.getLogger(__name__) +from tests.common import async_fire_time_changed MELACHA_PARAMS = [ - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 16, 0), - { - "state": STATE_ON, - "update": dt(2018, 9, 1, 20, 14), - "new_state": STATE_OFF, - }, + {"state": STATE_ON, "update": dt(2018, 9, 1, 20, 14), "new_state": STATE_OFF}, + id="currently_first_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 20, 21), - { - "state": STATE_OFF, - "update": dt(2018, 9, 2, 6, 21), - "new_state": STATE_OFF, - }, + {"state": STATE_OFF, "update": dt(2018, 9, 2, 6, 21), "new_state": STATE_OFF}, + id="after_first_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 7, 13, 1), - { - "state": STATE_OFF, - "update": dt(2018, 9, 7, 19, 4), - "new_state": STATE_ON, - }, + {"state": STATE_OFF, "update": dt(2018, 9, 7, 19, 4), "new_state": STATE_ON}, + id="friday_upcoming_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 8, 21, 25), - { - "state": STATE_OFF, - "update": dt(2018, 9, 9, 6, 27), - "new_state": STATE_OFF, - }, + {"state": STATE_OFF, "update": dt(2018, 9, 9, 6, 27), "new_state": STATE_OFF}, + id="upcoming_rosh_hashana", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 9, 21, 25), - { - "state": STATE_ON, - "update": dt(2018, 9, 10, 6, 28), - "new_state": STATE_ON, - }, + {"state": STATE_ON, "update": dt(2018, 9, 10, 6, 28), "new_state": STATE_ON}, + id="currently_rosh_hashana", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 10, 21, 25), - { - "state": STATE_ON, - "update": dt(2018, 9, 11, 6, 29), - "new_state": STATE_ON, - }, + {"state": STATE_ON, "update": dt(2018, 9, 11, 6, 29), "new_state": STATE_ON}, + id="second_day_rosh_hashana_night", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 11, 11, 25), - { - "state": STATE_ON, - "update": dt(2018, 9, 11, 19, 57), - "new_state": STATE_OFF, - }, + {"state": STATE_ON, "update": dt(2018, 9, 11, 19, 57), "new_state": STATE_OFF}, + id="second_day_rosh_hashana_day", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 29, 16, 25), - { - "state": STATE_ON, - "update": dt(2018, 9, 29, 19, 25), - "new_state": STATE_OFF, - }, + {"state": STATE_ON, "update": dt(2018, 9, 29, 19, 25), "new_state": STATE_OFF}, + id="currently_shabbat_chol_hamoed", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 29, 21, 25), - { - "state": STATE_OFF, - "update": dt(2018, 9, 30, 6, 48), - "new_state": STATE_OFF, - }, + {"state": STATE_OFF, "update": dt(2018, 9, 30, 6, 48), "new_state": STATE_OFF}, + id="upcoming_two_day_yomtov_in_diaspora", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 30, 21, 25), - { - "state": STATE_ON, - "update": dt(2018, 10, 1, 6, 49), - "new_state": STATE_ON, - }, + {"state": STATE_ON, "update": dt(2018, 10, 1, 6, 49), "new_state": STATE_ON}, + id="currently_first_day_of_two_day_yomtov_in_diaspora", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 10, 1, 21, 25), - { - "state": STATE_ON, - "update": dt(2018, 10, 2, 6, 50), - "new_state": STATE_ON, - }, + {"state": STATE_ON, "update": dt(2018, 10, 2, 6, 50), "new_state": STATE_ON}, + id="currently_second_day_of_two_day_yomtov_in_diaspora", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 9, 29, 21, 25), - { - "state": STATE_OFF, - "update": dt(2018, 9, 30, 6, 29), - "new_state": STATE_OFF, - }, + {"state": STATE_OFF, "update": dt(2018, 9, 30, 6, 29), "new_state": STATE_OFF}, + id="upcoming_one_day_yom_tov_in_israel", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 10, 1, 11, 25), - { - "state": STATE_ON, - "update": dt(2018, 10, 1, 19, 2), - "new_state": STATE_OFF, - }, + {"state": STATE_ON, "update": dt(2018, 10, 1, 19, 2), "new_state": STATE_OFF}, + id="currently_one_day_yom_tov_in_israel", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 10, 1, 21, 25), - { - "state": STATE_OFF, - "update": dt(2018, 10, 2, 6, 31), - "new_state": STATE_OFF, - }, + {"state": STATE_OFF, "update": dt(2018, 10, 2, 6, 31), "new_state": STATE_OFF}, + id="after_one_day_yom_tov_in_israel", ), ] -MELACHA_TEST_IDS = [ - "currently_first_shabbat", - "after_first_shabbat", - "friday_upcoming_shabbat", - "upcoming_rosh_hashana", - "currently_rosh_hashana", - "second_day_rosh_hashana_night", - "second_day_rosh_hashana_day", - "currently_shabbat_chol_hamoed", - "upcoming_two_day_yomtov_in_diaspora", - "currently_first_day_of_two_day_yomtov_in_diaspora", - "currently_second_day_of_two_day_yomtov_in_diaspora", - "upcoming_one_day_yom_tov_in_israel", - "currently_one_day_yom_tov_in_israel", - "after_one_day_yom_tov_in_israel", -] - @pytest.mark.parametrize( - ( - "now", - "candle_lighting", - "havdalah", - "diaspora", - "tzname", - "latitude", - "longitude", - "result", - ), - MELACHA_PARAMS, - ids=MELACHA_TEST_IDS, + ("location_data", "test_time", "results"), MELACHA_PARAMS, indirect=True ) +@pytest.mark.usefixtures("setup_at_time") async def test_issur_melacha_sensor( - hass: HomeAssistant, - now, - candle_lighting, - havdalah, - diaspora, - tzname, - latitude, - longitude, - result, + hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: dict[str, Any] ) -> None: """Test Issur Melacha sensor output.""" - time_zone = dt_util.get_time_zone(tzname) - test_time = now.replace(tzinfo=time_zone) + sensor_id = "binary_sensor.jewish_calendar_issur_melacha_in_effect" + assert hass.states.get(sensor_id).state == results["state"] - await hass.config.async_set_time_zone(tzname) - hass.config.latitude = latitude - hass.config.longitude = longitude - - with freeze_time(test_time): - entry = MockConfigEntry( - title=DEFAULT_NAME, - domain=DOMAIN, - data={ - CONF_LANGUAGE: "english", - CONF_DIASPORA: diaspora, - CONF_CANDLE_LIGHT_MINUTES: candle_lighting, - CONF_HAVDALAH_OFFSET_MINUTES: havdalah, - }, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get( - "binary_sensor.jewish_calendar_issur_melacha_in_effect" - ).state - == result["state"] - ) - - with freeze_time(result["update"]): - async_fire_time_changed(hass, result["update"]) - await hass.async_block_till_done() - assert ( - hass.states.get( - "binary_sensor.jewish_calendar_issur_melacha_in_effect" - ).state - == result["new_state"] - ) + freezer.move_to(results["update"]) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(sensor_id).state == results["new_state"] @pytest.mark.parametrize( - ( - "now", - "candle_lighting", - "havdalah", - "diaspora", - "tzname", - "latitude", - "longitude", - "result", - ), + ("location_data", "test_time", "results"), [ - make_nyc_test_params( - dt(2020, 10, 23, 17, 44, 59, 999999), [STATE_OFF, STATE_ON] - ), - make_nyc_test_params( - dt(2020, 10, 24, 18, 42, 59, 999999), [STATE_ON, STATE_OFF] - ), + ("New York", dt(2020, 10, 23, 17, 44, 59, 999999), [STATE_OFF, STATE_ON]), + ("New York", dt(2020, 10, 24, 18, 42, 59, 999999), [STATE_ON, STATE_OFF]), ], ids=["before_candle_lighting", "before_havdalah"], + indirect=True, ) +@pytest.mark.usefixtures("setup_at_time") async def test_issur_melacha_sensor_update( - hass: HomeAssistant, - now, - candle_lighting, - havdalah, - diaspora, - tzname, - latitude, - longitude, - result, + hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: list[str] ) -> None: """Test Issur Melacha sensor output.""" - time_zone = dt_util.get_time_zone(tzname) - test_time = now.replace(tzinfo=time_zone) + sensor_id = "binary_sensor.jewish_calendar_issur_melacha_in_effect" + assert hass.states.get(sensor_id).state == results[0] - await hass.config.async_set_time_zone(tzname) - hass.config.latitude = latitude - hass.config.longitude = longitude - - with freeze_time(test_time): - entry = MockConfigEntry( - title=DEFAULT_NAME, - domain=DOMAIN, - data={ - CONF_LANGUAGE: "english", - CONF_DIASPORA: diaspora, - CONF_CANDLE_LIGHT_MINUTES: candle_lighting, - CONF_HAVDALAH_OFFSET_MINUTES: havdalah, - }, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert ( - hass.states.get( - "binary_sensor.jewish_calendar_issur_melacha_in_effect" - ).state - == result[0] - ) - - test_time += timedelta(microseconds=1) - with freeze_time(test_time): - async_fire_time_changed(hass, test_time) - await hass.async_block_till_done() - assert ( - hass.states.get( - "binary_sensor.jewish_calendar_issur_melacha_in_effect" - ).state - == result[1] - ) + freezer.tick(timedelta(microseconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(sensor_id).state == results[1] async def test_no_discovery_info( diff --git a/tests/components/jewish_calendar/test_config_flow.py b/tests/components/jewish_calendar/test_config_flow.py index e00fe41749f..7a8b6b8df1e 100644 --- a/tests/components/jewish_calendar/test_config_flow.py +++ b/tests/components/jewish_calendar/test_config_flow.py @@ -57,10 +57,10 @@ async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No async def test_single_instance_allowed( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, + config_entry: MockConfigEntry, ) -> None: """Test we abort if already setup.""" - mock_config_entry.add_to_hass(hass) + config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -70,11 +70,11 @@ async def test_single_instance_allowed( assert result.get("reason") == "single_instance_allowed" -async def test_options(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: +async def test_options(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test updating options.""" - mock_config_entry.add_to_hass(hass) + 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_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -95,16 +95,16 @@ async def test_options(hass: HomeAssistant, mock_config_entry: MockConfigEntry) async def test_options_reconfigure( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test that updating the options of the Jewish Calendar integration triggers a value update.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert CONF_CANDLE_LIGHT_MINUTES not in mock_config_entry.options + assert CONF_CANDLE_LIGHT_MINUTES not in config_entry.options # Update the CONF_CANDLE_LIGHT_MINUTES option to a new value - result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ @@ -114,21 +114,17 @@ async def test_options_reconfigure( assert result["result"] # The value of the "upcoming_shabbat_candle_lighting" sensor should be the new value - assert ( - mock_config_entry.options[CONF_CANDLE_LIGHT_MINUTES] == DEFAULT_CANDLE_LIGHT + 1 - ) + assert config_entry.options[CONF_CANDLE_LIGHT_MINUTES] == DEFAULT_CANDLE_LIGHT + 1 -async def test_reconfigure( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: +async def test_reconfigure(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test starting a reconfigure flow.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() # init user flow - result = await mock_config_entry.start_reconfigure_flow(hass) + result = await config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" @@ -141,4 +137,4 @@ async def test_reconfigure( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - assert mock_config_entry.data[CONF_DIASPORA] is not DEFAULT_DIASPORA + assert config_entry.data[CONF_DIASPORA] is not DEFAULT_DIASPORA diff --git a/tests/components/jewish_calendar/test_init.py b/tests/components/jewish_calendar/test_init.py index 6a4f57513fa..88ba1334210 100644 --- a/tests/components/jewish_calendar/test_init.py +++ b/tests/components/jewish_calendar/test_init.py @@ -21,24 +21,24 @@ from tests.common import MockConfigEntry async def test_migrate_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, old_key: str, new_key: str, ) -> None: """Test unique id migration.""" - entry = MockConfigEntry(domain=DOMAIN, data={}) - entry.add_to_hass(hass) + config_entry.add_to_hass(hass) entity: er.RegistryEntry = entity_registry.async_get_or_create( domain=SENSOR_DOMAIN, platform=DOMAIN, - unique_id=f"{entry.entry_id}-{old_key}", - config_entry=entry, + unique_id=f"{config_entry.entry_id}-{old_key}", + config_entry=config_entry, ) assert entity.unique_id.endswith(f"-{old_key}") - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity_migrated = entity_registry.async_get(entity.entity_id) assert entity_migrated - assert entity_migrated.unique_id == f"{entry.entry_id}-{new_key}" + assert entity_migrated.unique_id == f"{config_entry.entry_id}-{new_key}" diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index bc9e69a9717..d38d20ab4d6 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -1,700 +1,549 @@ """The tests for the Jewish calendar sensors.""" -from datetime import datetime as dt, timedelta +from datetime import datetime as dt +from typing import Any -from freezegun import freeze_time from hdate.holidays import HolidayDatabase from hdate.parasha import Parasha import pytest -from homeassistant.components.jewish_calendar.const import ( - CONF_CANDLE_LIGHT_MINUTES, - CONF_DIASPORA, - CONF_HAVDALAH_OFFSET_MINUTES, - DEFAULT_NAME, - DOMAIN, -) +from homeassistant.components.jewish_calendar.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM +from homeassistant.const import CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import make_jerusalem_test_params, make_nyc_test_params - -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry -async def test_jewish_calendar_min_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("language", ["en", "he"]) +async def test_min_config(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test minimum jewish calendar configuration.""" - entry = MockConfigEntry(title=DEFAULT_NAME, domain=DOMAIN, data={}) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert hass.states.get("sensor.jewish_calendar_date") is not None - - -async def test_jewish_calendar_hebrew(hass: HomeAssistant) -> None: - """Test jewish calendar sensor with language set to hebrew.""" - entry = MockConfigEntry( - title=DEFAULT_NAME, domain=DOMAIN, data={"language": "hebrew"} - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert hass.states.get("sensor.jewish_calendar_date") is not None TEST_PARAMS = [ - ( + pytest.param( + "Jerusalem", dt(2018, 9, 3), - "UTC", - 31.778, - 35.235, - "english", + {"state": "23 Elul 5778"}, + "en", "date", - False, - "23 Elul 5778", - None, + id="date_output", ), - ( + pytest.param( + "Jerusalem", dt(2018, 9, 3), - "UTC", - 31.778, - 35.235, - "hebrew", + {"state": 'כ"ג אלול ה\' תשע"ח'}, + "he", "date", - False, - 'כ"ג אלול ה\' תשע"ח', - None, + id="date_output_hebrew", ), - ( + pytest.param( + "Jerusalem", dt(2018, 9, 10), - "UTC", - 31.778, - 35.235, - "hebrew", + {"state": "א' ראש השנה"}, + "he", "holiday", - False, - "א' ראש השנה", - None, + id="holiday", ), - ( + pytest.param( + "Jerusalem", dt(2018, 9, 10), - "UTC", - 31.778, - 35.235, - "english", - "holiday", - False, - "Rosh Hashana I", { - "device_class": "enum", - "friendly_name": "Jewish Calendar Holiday", - "icon": "mdi:calendar-star", - "id": "rosh_hashana_i", - "type": "YOM_TOV", - "options": HolidayDatabase(False).get_all_names("english"), + "state": "Rosh Hashana I", + "attr": { + "device_class": "enum", + "friendly_name": "Jewish Calendar Holiday", + "icon": "mdi:calendar-star", + "id": "rosh_hashana_i", + "type": "YOM_TOV", + "options": lambda: HolidayDatabase(False).get_all_names(), + }, }, + "en", + "holiday", + id="holiday_english", ), - ( + pytest.param( + "Jerusalem", dt(2024, 12, 31), - "UTC", - 31.778, - 35.235, - "english", + { + "state": "Chanukah, Rosh Chodesh", + "attr": { + "device_class": "enum", + "friendly_name": "Jewish Calendar Holiday", + "icon": "mdi:calendar-star", + "id": "chanukah, rosh_chodesh", + "type": "MELACHA_PERMITTED_HOLIDAY, ROSH_CHODESH", + "options": lambda: HolidayDatabase(False).get_all_names(), + }, + }, + "en", "holiday", - False, - "Chanukah, Rosh Chodesh", - { - "device_class": "enum", - "friendly_name": "Jewish Calendar Holiday", - "icon": "mdi:calendar-star", - "id": "chanukah, rosh_chodesh", - "type": "MELACHA_PERMITTED_HOLIDAY, ROSH_CHODESH", - "options": HolidayDatabase(False).get_all_names("english"), - }, + id="holiday_multiple", ), - ( + pytest.param( + "Jerusalem", dt(2018, 9, 8), - "UTC", - 31.778, - 35.235, - "hebrew", + { + "state": "נצבים", + "attr": { + "device_class": "enum", + "friendly_name": "Jewish Calendar Parshat Hashavua", + "icon": "mdi:book-open-variant", + "options": list(Parasha), + }, + }, + "he", "parshat_hashavua", - False, - "נצבים", - { - "device_class": "enum", - "friendly_name": "Jewish Calendar Parshat Hashavua", - "icon": "mdi:book-open-variant", - "options": list(Parasha), - }, + id="torah_reading", ), - ( + pytest.param( + "New York", dt(2018, 9, 8), - "America/New_York", - 40.7128, - -74.0060, - "hebrew", + {"state": dt(2018, 9, 8, 19, 47)}, + "he", "t_set_hakochavim", - True, - dt(2018, 9, 8, 19, 47), - None, + id="first_stars_ny", ), - ( + pytest.param( + "Jerusalem", dt(2018, 9, 8), - "Asia/Jerusalem", - 31.778, - 35.235, - "hebrew", + {"state": dt(2018, 9, 8, 19, 21)}, + "he", "t_set_hakochavim", - False, - dt(2018, 9, 8, 19, 21), - None, + id="first_stars_jerusalem", ), - ( + pytest.param( + "Jerusalem", dt(2018, 10, 14), - "Asia/Jerusalem", - 31.778, - 35.235, - "hebrew", + {"state": "לך לך"}, + "he", "parshat_hashavua", - False, - "לך לך", - None, + id="torah_reading_weekday", ), - ( + pytest.param( + "Jerusalem", dt(2018, 10, 14, 17, 0, 0), - "Asia/Jerusalem", - 31.778, - 35.235, - "hebrew", + {"state": "ה' מרחשוון ה' תשע\"ט"}, + "he", "date", - False, - "ה' מרחשוון ה' תשע\"ט", - None, + id="date_before_sunset", ), - ( + pytest.param( + "Jerusalem", dt(2018, 10, 14, 19, 0, 0), - "Asia/Jerusalem", - 31.778, - 35.235, - "hebrew", - "date", - False, - "ו' מרחשוון ה' תשע\"ט", { - "hebrew_year": "5779", - "hebrew_month_name": "מרחשוון", - "hebrew_day": "6", - "icon": "mdi:star-david", - "friendly_name": "Jewish Calendar Date", + "state": "ו' מרחשוון ה' תשע\"ט", + "attr": { + "hebrew_year": "5779", + "hebrew_month_name": "מרחשוון", + "hebrew_day": "6", + "icon": "mdi:star-david", + "friendly_name": "Jewish Calendar Date", + }, }, + "he", + "date", + id="date_after_sunset", ), ] -TEST_IDS = [ - "date_output", - "date_output_hebrew", - "holiday", - "holiday_english", - "holiday_multiple", - "torah_reading", - "first_stars_ny", - "first_stars_jerusalem", - "torah_reading_weekday", - "date_before_sunset", - "date_after_sunset", -] - @pytest.mark.parametrize( - ( - "now", - "tzname", - "latitude", - "longitude", - "language", - "sensor", - "diaspora", - "result", - "attrs", - ), + ("location_data", "test_time", "results", "language", "sensor"), TEST_PARAMS, - ids=TEST_IDS, + indirect=["location_data", "test_time", "results"], ) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time") async def test_jewish_calendar_sensor( - hass: HomeAssistant, - now, - tzname, - latitude, - longitude, - language, - sensor, - diaspora, - result, - attrs, + hass: HomeAssistant, results: dict[str, Any], sensor: str ) -> None: """Test Jewish calendar sensor output.""" - time_zone = dt_util.get_time_zone(tzname) - test_time = now.replace(tzinfo=time_zone) - - await hass.config.async_set_time_zone(tzname) - hass.config.latitude = latitude - hass.config.longitude = longitude - - with freeze_time(test_time): - entry = MockConfigEntry( - title=DEFAULT_NAME, - domain=DOMAIN, - data={ - CONF_LANGUAGE: language, - CONF_DIASPORA: diaspora, - }, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - future = test_time + timedelta(seconds=30) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - result = ( - dt_util.as_utc(result.replace(tzinfo=time_zone)).isoformat() - if isinstance(result, dt) - else result - ) + result = results["state"] + if isinstance(result, dt): + result = dt_util.as_utc(result).isoformat() sensor_object = hass.states.get(f"sensor.jewish_calendar_{sensor}") assert sensor_object.state == result - if attrs: + if attrs := results.get("attr"): assert sensor_object.attributes == attrs SHABBAT_PARAMS = [ - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 16, 0), { - "english_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12), - "english_upcoming_havdalah": dt(2018, 9, 1, 20, 10), - "english_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 12), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 10), - "english_parshat_hashavua": "Ki Tavo", - "hebrew_parshat_hashavua": "כי תבוא", + "en_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12), + "en_upcoming_havdalah": dt(2018, 9, 1, 20, 10), + "en_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 12), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 10), + "en_parshat_hashavua": "Ki Tavo", + "he_parshat_hashavua": "כי תבוא", }, + None, + id="currently_first_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 16, 0), { - "english_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12), - "english_upcoming_havdalah": dt(2018, 9, 1, 20, 18), - "english_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 12), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 18), - "english_parshat_hashavua": "Ki Tavo", - "hebrew_parshat_hashavua": "כי תבוא", + "en_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12), + "en_upcoming_havdalah": dt(2018, 9, 1, 20, 18), + "en_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 12), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 18), + "en_parshat_hashavua": "Ki Tavo", + "he_parshat_hashavua": "כי תבוא", }, - havdalah_offset=50, + 50, # Havdalah offset + id="currently_first_shabbat_with_havdalah_offset", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 20, 0), { - "english_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 12), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 10), - "english_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12), - "english_upcoming_havdalah": dt(2018, 9, 1, 20, 10), - "english_parshat_hashavua": "Ki Tavo", - "hebrew_parshat_hashavua": "כי תבוא", + "en_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 12), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 10), + "en_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12), + "en_upcoming_havdalah": dt(2018, 9, 1, 20, 10), + "en_parshat_hashavua": "Ki Tavo", + "he_parshat_hashavua": "כי תבוא", }, + None, + id="currently_first_shabbat_bein_hashmashot_lagging_date", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 20, 21), { - "english_upcoming_candle_lighting": dt(2018, 9, 7, 19), - "english_upcoming_havdalah": dt(2018, 9, 8, 19, 58), - "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 7, 19), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 8, 19, 58), - "english_parshat_hashavua": "Nitzavim", - "hebrew_parshat_hashavua": "נצבים", + "en_upcoming_candle_lighting": dt(2018, 9, 7, 19), + "en_upcoming_havdalah": dt(2018, 9, 8, 19, 58), + "en_upcoming_shabbat_candle_lighting": dt(2018, 9, 7, 19), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 8, 19, 58), + "en_parshat_hashavua": "Nitzavim", + "he_parshat_hashavua": "נצבים", }, + None, + id="after_first_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 7, 13, 1), { - "english_upcoming_candle_lighting": dt(2018, 9, 7, 19), - "english_upcoming_havdalah": dt(2018, 9, 8, 19, 58), - "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 7, 19), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 8, 19, 58), - "english_parshat_hashavua": "Nitzavim", - "hebrew_parshat_hashavua": "נצבים", + "en_upcoming_candle_lighting": dt(2018, 9, 7, 19), + "en_upcoming_havdalah": dt(2018, 9, 8, 19, 58), + "en_upcoming_shabbat_candle_lighting": dt(2018, 9, 7, 19), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 8, 19, 58), + "en_parshat_hashavua": "Nitzavim", + "he_parshat_hashavua": "נצבים", }, + None, + id="friday_upcoming_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 8, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), - "english_upcoming_havdalah": dt(2018, 9, 11, 19, 53), - "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 48), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 46), - "english_parshat_hashavua": "Vayeilech", - "hebrew_parshat_hashavua": "וילך", - "english_holiday": "Erev Rosh Hashana", - "hebrew_holiday": "ערב ראש השנה", + "en_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), + "en_upcoming_havdalah": dt(2018, 9, 11, 19, 53), + "en_upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 48), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 46), + "en_parshat_hashavua": "Vayeilech", + "he_parshat_hashavua": "וילך", + "en_holiday": "Erev Rosh Hashana", + "he_holiday": "ערב ראש השנה", }, + None, + id="upcoming_rosh_hashana", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 9, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), - "english_upcoming_havdalah": dt(2018, 9, 11, 19, 53), - "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 48), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 46), - "english_parshat_hashavua": "Vayeilech", - "hebrew_parshat_hashavua": "וילך", - "english_holiday": "Rosh Hashana I", - "hebrew_holiday": "א' ראש השנה", + "en_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), + "en_upcoming_havdalah": dt(2018, 9, 11, 19, 53), + "en_upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 48), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 46), + "en_parshat_hashavua": "Vayeilech", + "he_parshat_hashavua": "וילך", + "en_holiday": "Rosh Hashana I", + "he_holiday": "א' ראש השנה", }, + None, + id="currently_rosh_hashana", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 10, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), - "english_upcoming_havdalah": dt(2018, 9, 11, 19, 53), - "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 48), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 46), - "english_parshat_hashavua": "Vayeilech", - "hebrew_parshat_hashavua": "וילך", - "english_holiday": "Rosh Hashana II", - "hebrew_holiday": "ב' ראש השנה", + "en_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), + "en_upcoming_havdalah": dt(2018, 9, 11, 19, 53), + "en_upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 48), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 46), + "en_parshat_hashavua": "Vayeilech", + "he_parshat_hashavua": "וילך", + "en_holiday": "Rosh Hashana II", + "he_holiday": "ב' ראש השנה", }, + None, + id="second_day_rosh_hashana", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 28, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 28, 18, 25), - "english_upcoming_havdalah": dt(2018, 9, 29, 19, 22), - "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 28, 18, 25), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 29, 19, 22), - "english_parshat_hashavua": "none", - "hebrew_parshat_hashavua": "none", + "en_upcoming_candle_lighting": dt(2018, 9, 28, 18, 25), + "en_upcoming_havdalah": dt(2018, 9, 29, 19, 22), + "en_upcoming_shabbat_candle_lighting": dt(2018, 9, 28, 18, 25), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 29, 19, 22), + "en_parshat_hashavua": "none", + "he_parshat_hashavua": "none", }, + None, + id="currently_shabbat_chol_hamoed", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 29, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), - "english_upcoming_havdalah": dt(2018, 10, 2, 19, 17), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 13), - "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 11), - "english_parshat_hashavua": "Bereshit", - "hebrew_parshat_hashavua": "בראשית", - "english_holiday": "Hoshana Raba", - "hebrew_holiday": "הושענא רבה", + "en_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), + "en_upcoming_havdalah": dt(2018, 10, 2, 19, 17), + "en_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 13), + "en_upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 11), + "en_parshat_hashavua": "Bereshit", + "he_parshat_hashavua": "בראשית", + "en_holiday": "Hoshana Raba", + "he_holiday": "הושענא רבה", }, + None, + id="upcoming_two_day_yomtov_in_diaspora", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 30, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), - "english_upcoming_havdalah": dt(2018, 10, 2, 19, 17), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 13), - "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 11), - "english_parshat_hashavua": "Bereshit", - "hebrew_parshat_hashavua": "בראשית", - "english_holiday": "Shmini Atzeret", - "hebrew_holiday": "שמיני עצרת", + "en_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), + "en_upcoming_havdalah": dt(2018, 10, 2, 19, 17), + "en_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 13), + "en_upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 11), + "en_parshat_hashavua": "Bereshit", + "he_parshat_hashavua": "בראשית", + "en_holiday": "Shmini Atzeret", + "he_holiday": "שמיני עצרת", }, + None, + id="currently_first_day_of_two_day_yomtov_in_diaspora", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 10, 1, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), - "english_upcoming_havdalah": dt(2018, 10, 2, 19, 17), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 13), - "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 11), - "english_parshat_hashavua": "Bereshit", - "hebrew_parshat_hashavua": "בראשית", - "english_holiday": "Simchat Torah", - "hebrew_holiday": "שמחת תורה", + "en_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), + "en_upcoming_havdalah": dt(2018, 10, 2, 19, 17), + "en_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 13), + "en_upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 11), + "en_parshat_hashavua": "Bereshit", + "he_parshat_hashavua": "בראשית", + "en_holiday": "Simchat Torah", + "he_holiday": "שמחת תורה", }, + None, + id="currently_second_day_of_two_day_yomtov_in_diaspora", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 9, 29, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 30, 17, 45), - "english_upcoming_havdalah": dt(2018, 10, 1, 19, 1), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), - "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), - "english_parshat_hashavua": "Bereshit", - "hebrew_parshat_hashavua": "בראשית", - "english_holiday": "Hoshana Raba", - "hebrew_holiday": "הושענא רבה", + "en_upcoming_candle_lighting": dt(2018, 9, 30, 17, 46), + "en_upcoming_havdalah": dt(2018, 10, 1, 19, 1), + "en_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), + "en_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), + "en_parshat_hashavua": "Bereshit", + "he_parshat_hashavua": "בראשית", + "en_holiday": "Hoshana Raba", + "he_holiday": "הושענא רבה", }, + None, + id="upcoming_one_day_yom_tov_in_israel", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 9, 30, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 30, 17, 45), - "english_upcoming_havdalah": dt(2018, 10, 1, 19, 1), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), - "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), - "english_parshat_hashavua": "Bereshit", - "hebrew_parshat_hashavua": "בראשית", - "english_holiday": "Shmini Atzeret, Simchat Torah", - "hebrew_holiday": "שמיני עצרת, שמחת תורה", + "en_upcoming_candle_lighting": dt(2018, 9, 30, 17, 46), + "en_upcoming_havdalah": dt(2018, 10, 1, 19, 1), + "en_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), + "en_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), + "en_parshat_hashavua": "Bereshit", + "he_parshat_hashavua": "בראשית", + "en_holiday": "Shmini Atzeret, Simchat Torah", + "he_holiday": "שמיני עצרת, שמחת תורה", }, + None, + id="currently_one_day_yom_tov_in_israel", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 10, 1, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 10, 5, 17, 39), - "english_upcoming_havdalah": dt(2018, 10, 6, 18, 54), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), - "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), - "english_parshat_hashavua": "Bereshit", - "hebrew_parshat_hashavua": "בראשית", + "en_upcoming_candle_lighting": dt(2018, 10, 5, 17, 39), + "en_upcoming_havdalah": dt(2018, 10, 6, 18, 54), + "en_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), + "en_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), + "en_parshat_hashavua": "Bereshit", + "he_parshat_hashavua": "בראשית", }, + None, + id="after_one_day_yom_tov_in_israel", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2016, 6, 11, 8, 25), { - "english_upcoming_candle_lighting": dt(2016, 6, 10, 20, 9), - "english_upcoming_havdalah": dt(2016, 6, 13, 21, 19), - "english_upcoming_shabbat_candle_lighting": dt(2016, 6, 10, 20, 9), - "english_upcoming_shabbat_havdalah": "unknown", - "english_parshat_hashavua": "Bamidbar", - "hebrew_parshat_hashavua": "במדבר", - "english_holiday": "Erev Shavuot", - "hebrew_holiday": "ערב שבועות", + "en_upcoming_candle_lighting": dt(2016, 6, 10, 20, 9), + "en_upcoming_havdalah": dt(2016, 6, 13, 21, 19), + "en_upcoming_shabbat_candle_lighting": dt(2016, 6, 10, 20, 9), + "en_upcoming_shabbat_havdalah": "unknown", + "en_parshat_hashavua": "Bamidbar", + "he_parshat_hashavua": "במדבר", + "en_holiday": "Erev Shavuot", + "he_holiday": "ערב שבועות", }, + None, + id="currently_first_day_of_three_day_type1_yomtov_in_diaspora", # Type 1 = Sat/Sun/Mon ), - make_nyc_test_params( + pytest.param( + "New York", dt(2016, 6, 12, 8, 25), { - "english_upcoming_candle_lighting": dt(2016, 6, 10, 20, 9), - "english_upcoming_havdalah": dt(2016, 6, 13, 21, 19), - "english_upcoming_shabbat_candle_lighting": dt(2016, 6, 17, 20, 12), - "english_upcoming_shabbat_havdalah": dt(2016, 6, 18, 21, 21), - "english_parshat_hashavua": "Nasso", - "hebrew_parshat_hashavua": "נשא", - "english_holiday": "Shavuot", - "hebrew_holiday": "שבועות", + "en_upcoming_candle_lighting": dt(2016, 6, 10, 20, 9), + "en_upcoming_havdalah": dt(2016, 6, 13, 21, 19), + "en_upcoming_shabbat_candle_lighting": dt(2016, 6, 17, 20, 12), + "en_upcoming_shabbat_havdalah": dt(2016, 6, 18, 21, 21), + "en_parshat_hashavua": "Nasso", + "he_parshat_hashavua": "נשא", + "en_holiday": "Shavuot", + "he_holiday": "שבועות", }, + None, + id="currently_second_day_of_three_day_type1_yomtov_in_diaspora", # Type 1 = Sat/Sun/Mon ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2017, 9, 21, 8, 25), { - "english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), - "english_upcoming_havdalah": dt(2017, 9, 23, 19, 11), - "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 17, 56), - "english_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), - "english_parshat_hashavua": "Ha'Azinu", - "hebrew_parshat_hashavua": "האזינו", - "english_holiday": "Rosh Hashana I", - "hebrew_holiday": "א' ראש השנה", + "en_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), + "en_upcoming_havdalah": dt(2017, 9, 23, 19, 11), + "en_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 17, 56), + "en_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), + "en_parshat_hashavua": "Ha'Azinu", + "he_parshat_hashavua": "האזינו", + "en_holiday": "Rosh Hashana I", + "he_holiday": "א' ראש השנה", }, + None, + id="currently_first_day_of_three_day_type2_yomtov_in_israel", # Type 2 = Thurs/Fri/Sat ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2017, 9, 22, 8, 25), { - "english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), - "english_upcoming_havdalah": dt(2017, 9, 23, 19, 11), - "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 17, 56), - "english_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), - "english_parshat_hashavua": "Ha'Azinu", - "hebrew_parshat_hashavua": "האזינו", - "english_holiday": "Rosh Hashana II", - "hebrew_holiday": "ב' ראש השנה", + "en_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), + "en_upcoming_havdalah": dt(2017, 9, 23, 19, 11), + "en_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 17, 56), + "en_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), + "en_parshat_hashavua": "Ha'Azinu", + "he_parshat_hashavua": "האזינו", + "en_holiday": "Rosh Hashana II", + "he_holiday": "ב' ראש השנה", }, + None, + id="currently_second_day_of_three_day_type2_yomtov_in_israel", # Type 2 = Thurs/Fri/Sat ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2017, 9, 23, 8, 25), { - "english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), - "english_upcoming_havdalah": dt(2017, 9, 23, 19, 11), - "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 17, 56), - "english_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), - "english_parshat_hashavua": "Ha'Azinu", - "hebrew_parshat_hashavua": "האזינו", - "english_holiday": "", - "hebrew_holiday": "", + "en_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), + "en_upcoming_havdalah": dt(2017, 9, 23, 19, 11), + "en_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 17, 56), + "en_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), + "en_parshat_hashavua": "Ha'Azinu", + "he_parshat_hashavua": "האזינו", + "en_holiday": "", + "he_holiday": "", }, + None, + id="currently_third_day_of_three_day_type2_yomtov_in_israel", # Type 2 = Thurs/Fri/Sat ), ] -SHABBAT_TEST_IDS = [ - "currently_first_shabbat", - "currently_first_shabbat_with_havdalah_offset", - "currently_first_shabbat_bein_hashmashot_lagging_date", - "after_first_shabbat", - "friday_upcoming_shabbat", - "upcoming_rosh_hashana", - "currently_rosh_hashana", - "second_day_rosh_hashana", - "currently_shabbat_chol_hamoed", - "upcoming_two_day_yomtov_in_diaspora", - "currently_first_day_of_two_day_yomtov_in_diaspora", - "currently_second_day_of_two_day_yomtov_in_diaspora", - "upcoming_one_day_yom_tov_in_israel", - "currently_one_day_yom_tov_in_israel", - "after_one_day_yom_tov_in_israel", - # Type 1 = Sat/Sun/Mon - "currently_first_day_of_three_day_type1_yomtov_in_diaspora", - "currently_second_day_of_three_day_type1_yomtov_in_diaspora", - # Type 2 = Thurs/Fri/Sat - "currently_first_day_of_three_day_type2_yomtov_in_israel", - "currently_second_day_of_three_day_type2_yomtov_in_israel", - "currently_third_day_of_three_day_type2_yomtov_in_israel", -] - -@pytest.mark.parametrize("language", ["english", "hebrew"]) +@pytest.mark.parametrize("language", ["en", "he"]) @pytest.mark.parametrize( - ( - "now", - "candle_lighting", - "havdalah", - "diaspora", - "tzname", - "latitude", - "longitude", - "result", - ), + ("location_data", "test_time", "results", "havdalah_offset"), SHABBAT_PARAMS, - ids=SHABBAT_TEST_IDS, + indirect=("location_data", "test_time", "results"), ) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time") async def test_shabbat_times_sensor( - hass: HomeAssistant, - language, - now, - candle_lighting, - havdalah, - diaspora, - tzname, - latitude, - longitude, - result, + hass: HomeAssistant, results: dict[str, Any], language: str ) -> None: """Test sensor output for upcoming shabbat/yomtov times.""" - time_zone = dt_util.get_time_zone(tzname) - test_time = now.replace(tzinfo=time_zone) - - await hass.config.async_set_time_zone(tzname) - hass.config.latitude = latitude - hass.config.longitude = longitude - - with freeze_time(test_time): - entry = MockConfigEntry( - title=DEFAULT_NAME, - domain=DOMAIN, - data={ - CONF_LANGUAGE: language, - CONF_DIASPORA: diaspora, - }, - options={ - CONF_CANDLE_LIGHT_MINUTES: candle_lighting, - CONF_HAVDALAH_OFFSET_MINUTES: havdalah, - }, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - future = test_time + timedelta(seconds=30) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - for sensor_type, result_value in result.items(): + for sensor_type, result_value in results.items(): if not sensor_type.startswith(language): continue sensor_type = sensor_type.replace(f"{language}_", "") - result_value = ( - dt_util.as_utc(result_value).isoformat() - if isinstance(result_value, dt) - else result_value - ) + if isinstance(result_value, dt): + result_value = dt_util.as_utc(result_value).isoformat() assert hass.states.get(f"sensor.jewish_calendar_{sensor_type}").state == str( result_value ), f"Value for {sensor_type}" -OMER_PARAMS = [ - (dt(2019, 4, 21, 0), "1"), - (dt(2019, 4, 21, 23), "2"), - (dt(2019, 5, 23, 0), "33"), - (dt(2019, 6, 8, 0), "49"), - (dt(2019, 6, 9, 0), "0"), - (dt(2019, 1, 1, 0), "0"), -] -OMER_TEST_IDS = [ - "first_day_of_omer", - "first_day_of_omer_after_tzeit", - "lag_baomer", - "last_day_of_omer", - "shavuot_no_omer", - "jan_1st_no_omer", -] - - -@pytest.mark.parametrize(("test_time", "result"), OMER_PARAMS, ids=OMER_TEST_IDS) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_omer_sensor(hass: HomeAssistant, test_time, result) -> None: +@pytest.mark.parametrize( + ("test_time", "results"), + [ + pytest.param(dt(2019, 4, 21, 0), "1", id="first_day_of_omer"), + pytest.param(dt(2019, 4, 21, 23), "2", id="first_day_of_omer_after_tzeit"), + pytest.param(dt(2019, 5, 23, 0), "33", id="lag_baomer"), + pytest.param(dt(2019, 6, 8, 0), "49", id="last_day_of_omer"), + pytest.param(dt(2019, 6, 9, 0), "0", id="shavuot_no_omer"), + pytest.param(dt(2019, 1, 1, 0), "0", id="jan_1st_no_omer"), + ], + indirect=True, +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time") +async def test_omer_sensor(hass: HomeAssistant, results: str) -> None: """Test Omer Count sensor output.""" - test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone)) - - with freeze_time(test_time): - entry = MockConfigEntry(title=DEFAULT_NAME, domain=DOMAIN) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - future = test_time + timedelta(seconds=30) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get("sensor.jewish_calendar_day_of_the_omer").state == result + assert hass.states.get("sensor.jewish_calendar_day_of_the_omer").state == results -DAFYOMI_PARAMS = [ - (dt(2014, 4, 28, 0), "Beitzah 29"), - (dt(2020, 1, 4, 0), "Niddah 73"), - (dt(2020, 1, 5, 0), "Berachos 2"), - (dt(2020, 3, 7, 0), "Berachos 64"), - (dt(2020, 3, 8, 0), "Shabbos 2"), -] -DAFYOMI_TEST_IDS = [ - "randomly_picked_date", - "end_of_cycle13", - "start_of_cycle14", - "cycle14_end_of_berachos", - "cycle14_start_of_shabbos", -] - - -@pytest.mark.parametrize(("test_time", "result"), DAFYOMI_PARAMS, ids=DAFYOMI_TEST_IDS) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_dafyomi_sensor(hass: HomeAssistant, test_time, result) -> None: +@pytest.mark.parametrize( + ("test_time", "results"), + [ + pytest.param(dt(2014, 4, 28, 0), "Beitzah 29", id="randomly_picked_date"), + pytest.param(dt(2020, 1, 4, 0), "Niddah 73", id="end_of_cycle13"), + pytest.param(dt(2020, 1, 5, 0), "Berachos 2", id="start_of_cycle14"), + pytest.param(dt(2020, 3, 7, 0), "Berachos 64", id="cycle14_end_of_berachos"), + pytest.param(dt(2020, 3, 8, 0), "Shabbos 2", id="cycle14_start_of_shabbos"), + ], + indirect=True, +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time") +async def test_dafyomi_sensor(hass: HomeAssistant, results: str) -> None: """Test Daf Yomi sensor output.""" - test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone)) - - with freeze_time(test_time): - entry = MockConfigEntry(title=DEFAULT_NAME, domain=DOMAIN) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - future = test_time + timedelta(seconds=30) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get("sensor.jewish_calendar_daf_yomi").state == result + assert hass.states.get("sensor.jewish_calendar_daf_yomi").state == results async def test_no_discovery_info( diff --git a/tests/components/jewish_calendar/test_service.py b/tests/components/jewish_calendar/test_service.py index 9eb80e5e7f0..4b3f31d11d4 100644 --- a/tests/components/jewish_calendar/test_service.py +++ b/tests/components/jewish_calendar/test_service.py @@ -2,52 +2,84 @@ import datetime as dt -from hdate.translator import Language import pytest from homeassistant.components.jewish_calendar.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry - @pytest.mark.parametrize( - ("test_date", "nusach", "language", "expected"), + ("test_time", "service_data", "expected"), [ - pytest.param(dt.date(2025, 3, 20), "sfarad", "he", "", id="no_blessing"), pytest.param( - dt.date(2025, 5, 20), - "ashkenaz", - "he", + dt.datetime(2025, 3, 20, 21, 0), + { + "date": dt.date(2025, 3, 20), + "nusach": "sfarad", + "language": "he", + "after_sunset": False, + }, + "", + id="no_blessing", + ), + pytest.param( + dt.datetime(2025, 3, 20, 21, 0), + { + "date": dt.date(2025, 5, 20), + "nusach": "ashkenaz", + "language": "he", + "after_sunset": False, + }, "היום שבעה ושלושים יום שהם חמישה שבועות ושני ימים בעומר", id="ahskenaz-hebrew", ), pytest.param( - dt.date(2025, 5, 20), - "sfarad", - "en", + dt.datetime(2025, 3, 20, 21, 0), + { + "date": dt.date(2025, 5, 20), + "nusach": "sfarad", + "language": "en", + "after_sunset": True, + }, + "Today is the thirty-eighth day, which are five weeks and three days of the Omer", + id="sefarad-english-after-sunset", + ), + pytest.param( + dt.datetime(2025, 3, 20, 21, 0), + { + "date": dt.date(2025, 5, 20), + "nusach": "sfarad", + "language": "en", + "after_sunset": False, + }, "Today is the thirty-seventh day, which are five weeks and two days of the Omer", - id="sefarad-english", + id="sefarad-english-before-sunset", + ), + pytest.param( + dt.datetime(2025, 5, 20, 21, 0), + {"nusach": "sfarad", "language": "en"}, + "Today is the thirty-eighth day, which are five weeks and three days of the Omer", + id="sefarad-english-after-sunset-without-date", + ), + pytest.param( + dt.datetime(2025, 5, 20, 6, 0), + {"nusach": "sfarad"}, + "היום שבעה ושלושים יום שהם חמישה שבועות ושני ימים לעומר", + id="sefarad-english-before-sunset-without-date", ), ], + indirect=["test_time"], ) +@pytest.mark.usefixtures("setup_at_time") async def test_get_omer_blessing( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - test_date: dt.date, - nusach: str, - language: Language, - expected: str, + hass: HomeAssistant, service_data: dict[str, str | dt.date | bool], expected: str ) -> None: """Test get omer blessing.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() result = await hass.services.async_call( DOMAIN, "count_omer", - {"date": test_date, "nusach": nusach, "language": language}, + service_data, blocking=True, return_response=True, ) diff --git a/tests/components/kulersky/test_config_flow.py b/tests/components/kulersky/test_config_flow.py index a2f3949bd07..7615e94d2f0 100644 --- a/tests/components/kulersky/test_config_flow.py +++ b/tests/components/kulersky/test_config_flow.py @@ -1,105 +1,182 @@ """Test the Kuler Sky config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, Mock, patch import pykulersky -from homeassistant import config_entries +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.kulersky.config_flow import DOMAIN +from homeassistant.config_entries import ( + SOURCE_BLUETOOTH, + SOURCE_INTEGRATION_DISCOVERY, + SOURCE_USER, +) +from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device -async def test_flow_success(hass: HomeAssistant) -> None: - """Test we get the form.""" +KULERSKY_SERVICE_INFO = BluetoothServiceInfoBleak( + name="KulerLight", + manufacturer_data={}, + service_data={}, + service_uuids=["8d96a001-0002-64c2-0001-9acc4838521c"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="KulerLight", + manufacturer_data={}, + service_data={}, + service_uuids=["8d96a001-0002-64c2-0001-9acc4838521c"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "KulerLight"), + time=0, + connectable=True, + tx_power=-127, +) + +async def test_bluetooth_discovery(hass: HomeAssistant) -> None: + """Test discovery via bluetooth with a valid device.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=KULERSKY_SERVICE_INFO, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert result["step_id"] == "user" - light = MagicMock(spec=pykulersky.Light) - light.address = "AA:BB:CC:11:22:33" - light.name = "Bedroom" - with ( - patch( - "homeassistant.components.kulersky.config_flow.pykulersky.discover", - return_value=[light], - ), - patch( - "homeassistant.components.kulersky.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( + with patch("pykulersky.Light", Mock(return_value=AsyncMock())): + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {}, + {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, ) - await hass.async_block_till_done() + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Kuler Sky" - assert result2["data"] == {} - - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "KulerLight (EEFF)" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + } -async def test_flow_no_devices_found(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_integration_discovery(hass: HomeAssistant) -> None: + """Test discovery via bluetooth with a valid device.""" + with patch( + "homeassistant.components.kulersky.config_flow.async_last_service_info", + return_value=KULERSKY_SERVICE_INFO, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "KulerLight (EEFF)" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + } + + +async def test_integration_discovery_no_last_service_info(hass: HomeAssistant) -> None: + """Test discovery via bluetooth with a valid device.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "AA:BB:CC:DD:EE:FF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + } + + +async def test_user_setup(hass: HomeAssistant) -> None: + """Test the user manually setting up the integration.""" + with patch( + "homeassistant.components.kulersky.config_flow.async_discovered_service_info", + return_value=[ + KULERSKY_SERVICE_INFO, + KULERSKY_SERVICE_INFO, + ], # Pass twice to test duplicate logic + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("pykulersky.Light", Mock(return_value=AsyncMock())): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "KulerLight (EEFF)" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + } + + +async def test_user_setup_no_devices(hass: HomeAssistant) -> None: + """Test the user manually setting up the integration.""" + with patch( + "homeassistant.components.kulersky.config_flow.async_discovered_service_info", + return_value=[], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test a connection error trying to set up.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=KULERSKY_SERVICE_INFO, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert result["step_id"] == "user" - with ( - patch( - "homeassistant.components.kulersky.config_flow.pykulersky.discover", - return_value=[], - ), - patch( - "homeassistant.components.kulersky.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( + with patch("pykulersky.Light", Mock(side_effect=pykulersky.PykulerskyException)): + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {}, + {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, ) - await hass.async_block_till_done() + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "no_devices_found" - assert len(mock_setup_entry.mock_calls) == 0 + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" -async def test_flow_exceptions_caught(hass: HomeAssistant) -> None: - """Test we get the form.""" - +async def test_unexpected_error(hass: HomeAssistant) -> None: + """Test an unexpected error trying to set up.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=KULERSKY_SERVICE_INFO, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert result["step_id"] == "user" - with ( - patch( - "homeassistant.components.kulersky.config_flow.pykulersky.discover", - side_effect=pykulersky.PykulerskyException("TEST"), - ), - patch( - "homeassistant.components.kulersky.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( + with patch("pykulersky.Light", Mock(side_effect=Exception)): + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {}, + {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, ) - await hass.async_block_till_done() + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "no_devices_found" - assert len(mock_setup_entry.mock_calls) == 0 + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "unknown" diff --git a/tests/components/kulersky/test_init.py b/tests/components/kulersky/test_init.py new file mode 100644 index 00000000000..54c5f146a61 --- /dev/null +++ b/tests/components/kulersky/test_init.py @@ -0,0 +1,65 @@ +"""Tests for init methods.""" + +from homeassistant.components.kulersky.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_migrate_entry( + hass: HomeAssistant, +) -> None: + """Test migrate config entry from v1 to v2.""" + + mock_config_entry_v1 = MockConfigEntry( + version=1, + domain=DOMAIN, + title="KulerSky", + ) + + mock_config_entry_v1.add_to_hass(hass) + + dev_reg = dr.async_get(hass) + # Create device registry entries for old integration + dev_reg.async_get_or_create( + config_entry_id=mock_config_entry_v1.entry_id, + identifiers={(DOMAIN, "AA:BB:CC:11:22:33")}, + name="KuLight 1", + ) + dev_reg.async_get_or_create( + config_entry_id=mock_config_entry_v1.entry_id, + identifiers={(DOMAIN, "AA:BB:CC:44:55:66")}, + name="KuLight 2", + ) + await hass.config_entries.async_setup(mock_config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v1.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry_v1.version == 2 + assert mock_config_entry_v1.unique_id == "AA:BB:CC:11:22:33" + assert mock_config_entry_v1.data == { + CONF_ADDRESS: "AA:BB:CC:11:22:33", + } + + +async def test_migrate_entry_no_devices_found( + hass: HomeAssistant, +) -> None: + """Test migrate config entry from v1 to v2.""" + + mock_config_entry_v1 = MockConfigEntry( + version=1, + domain=DOMAIN, + title="KulerSky", + ) + + mock_config_entry_v1.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v1.state is ConfigEntryState.MIGRATION_ERROR + assert mock_config_entry_v1.version == 1 diff --git a/tests/components/kulersky/test_light.py b/tests/components/kulersky/test_light.py index 230a2562282..bde60579af7 100644 --- a/tests/components/kulersky/test_light.py +++ b/tests/components/kulersky/test_light.py @@ -1,16 +1,13 @@ """Test the Kuler Sky lights.""" -from collections.abc import AsyncGenerator -from unittest.mock import MagicMock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch +from bleak.backends.device import BLEDevice import pykulersky import pytest -from homeassistant.components.kulersky.const import ( - DATA_ADDRESSES, - DATA_DISCOVERY_SUBSCRIPTION, - DOMAIN, -) +from homeassistant.components.kulersky.const import DOMAIN from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, @@ -26,6 +23,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, + CONF_ADDRESS, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -37,26 +35,43 @@ from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.fixture +def mock_ble_device() -> Generator[MagicMock]: + """Mock BLEDevice.""" + with patch( + "homeassistant.components.kulersky.async_ble_device_from_address", + return_value=BLEDevice( + address="AA:BB:CC:11:22:33", name="Bedroom", rssi=-50, details={} + ), + ) as ble_device: + yield ble_device + + @pytest.fixture async def mock_entry() -> MockConfigEntry: """Create a mock light entity.""" - return MockConfigEntry(domain=DOMAIN) + return MockConfigEntry( + domain=DOMAIN, + data={CONF_ADDRESS: "AA:BB:CC:11:22:33"}, + title="Bedroom", + version=2, + ) @pytest.fixture async def mock_light( - hass: HomeAssistant, mock_entry: MockConfigEntry -) -> AsyncGenerator[MagicMock]: - """Create a mock light entity.""" - - light = MagicMock(spec=pykulersky.Light) + hass: HomeAssistant, mock_entry: MockConfigEntry, mock_ble_device: MagicMock +) -> Generator[AsyncMock]: + """Mock pykulersky light.""" + light = AsyncMock() light.address = "AA:BB:CC:11:22:33" light.name = "Bedroom" light.connect.return_value = True light.get_color.return_value = (0, 0, 0, 0) + with patch( - "homeassistant.components.kulersky.light.pykulersky.discover", - return_value=[light], + "pykulersky.Light", + return_value=light, ): mock_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_entry.entry_id) @@ -67,7 +82,7 @@ async def mock_light( yield light -async def test_init(hass: HomeAssistant, mock_light: MagicMock) -> None: +async def test_init(hass: HomeAssistant, mock_light: AsyncMock) -> None: """Test platform setup.""" state = hass.states.get("light.bedroom") assert state.state == STATE_OFF @@ -83,24 +98,14 @@ async def test_init(hass: HomeAssistant, mock_light: MagicMock) -> None: ATTR_RGBW_COLOR: None, } - with patch.object(hass.loop, "stop"): - await hass.async_stop() - await hass.async_block_till_done() - - assert mock_light.disconnect.called - async def test_remove_entry( hass: HomeAssistant, mock_light: MagicMock, mock_entry: MockConfigEntry ) -> None: """Test platform setup.""" - assert hass.data[DOMAIN][DATA_ADDRESSES] == {"AA:BB:CC:11:22:33"} - assert DATA_DISCOVERY_SUBSCRIPTION in hass.data[DOMAIN] - await hass.config_entries.async_remove(mock_entry.entry_id) assert mock_light.disconnect.called - assert DOMAIN not in hass.data async def test_remove_entry_exceptions_caught( diff --git a/tests/components/lamarzocco/__init__.py b/tests/components/lamarzocco/__init__.py index f6ca0fe40df..80493aa83c9 100644 --- a/tests/components/lamarzocco/__init__.py +++ b/tests/components/lamarzocco/__init__.py @@ -1,6 +1,6 @@ """Mock inputs for tests.""" -from pylamarzocco.const import MachineModel +from pylamarzocco.const import ModelName from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -19,10 +19,10 @@ PASSWORD_SELECTION = { USER_INPUT = PASSWORD_SELECTION | {CONF_USERNAME: "username"} SERIAL_DICT = { - MachineModel.GS3_AV: "GS012345", - MachineModel.GS3_MP: "GS012345", - MachineModel.LINEA_MICRA: "MR012345", - MachineModel.LINEA_MINI: "LM012345", + ModelName.GS3_AV: "GS012345", + ModelName.GS3_MP: "GS012345", + ModelName.LINEA_MICRA: "MR012345", + ModelName.LINEA_MINI: "LM012345", } WAKE_UP_SLEEP_ENTRY_IDS = ["Os2OswX", "aXFz5bJ"] @@ -37,15 +37,13 @@ async def async_init_integration( await hass.async_block_till_done() -def get_bluetooth_service_info( - model: MachineModel, serial: str -) -> BluetoothServiceInfo: +def get_bluetooth_service_info(model: ModelName, serial: str) -> BluetoothServiceInfo: """Return a mocked BluetoothServiceInfo.""" - if model in (MachineModel.GS3_AV, MachineModel.GS3_MP): + if model in (ModelName.GS3_AV, ModelName.GS3_MP): name = f"GS3_{serial}" - elif model == MachineModel.LINEA_MINI: + elif model == ModelName.LINEA_MINI: name = f"MINI_{serial}" - elif model == MachineModel.LINEA_MICRA: + elif model == ModelName.LINEA_MICRA: name = f"MICRA_{serial}" return BluetoothServiceInfo( name=name, diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index 658e0dd96bc..c7530d464db 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -1,37 +1,26 @@ """Lamarzocco session fixtures.""" from collections.abc import Generator -import json -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch from bleak.backends.device import BLEDevice -from pylamarzocco.const import FirmwareType, MachineModel, SteamLevel -from pylamarzocco.devices.machine import LaMarzoccoMachine -from pylamarzocco.models import LaMarzoccoDeviceInfo +from pylamarzocco.const import ModelName +from pylamarzocco.models import ( + Thing, + ThingDashboardConfig, + ThingSchedulingSettings, + ThingSettings, + ThingStatistics, +) import pytest from homeassistant.components.lamarzocco.const import DOMAIN -from homeassistant.const import ( - CONF_ADDRESS, - CONF_HOST, - CONF_MODEL, - CONF_NAME, - CONF_TOKEN, -) +from homeassistant.const import CONF_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from . import SERIAL_DICT, USER_INPUT, async_init_integration -from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.lamarzocco.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -42,33 +31,11 @@ def mock_config_entry( return MockConfigEntry( title="My LaMarzocco", domain=DOMAIN, - version=2, + version=3, data=USER_INPUT | { - CONF_MODEL: mock_lamarzocco.model, CONF_ADDRESS: "00:00:00:00:00:00", - CONF_HOST: "host", CONF_TOKEN: "token", - CONF_NAME: "GS3", - }, - unique_id=mock_lamarzocco.serial_number, - ) - - -@pytest.fixture -def mock_config_entry_no_local_connection( - hass: HomeAssistant, mock_lamarzocco: MagicMock -) -> MockConfigEntry: - """Return the default mocked config entry.""" - return MockConfigEntry( - title="My LaMarzocco", - domain=DOMAIN, - version=2, - data=USER_INPUT - | { - CONF_MODEL: mock_lamarzocco.model, - CONF_TOKEN: "token", - CONF_NAME: "GS3", }, unique_id=mock_lamarzocco.serial_number, ) @@ -85,26 +52,13 @@ async def init_integration( @pytest.fixture -def device_fixture() -> MachineModel: +def device_fixture() -> ModelName: """Return the device fixture for a specific device.""" - return MachineModel.GS3_AV + return ModelName.GS3_AV -@pytest.fixture -def mock_device_info(device_fixture: MachineModel) -> LaMarzoccoDeviceInfo: - """Return a mocked La Marzocco device info.""" - return LaMarzoccoDeviceInfo( - model=device_fixture, - serial_number=SERIAL_DICT[device_fixture], - name="GS3", - communication_key="token", - ) - - -@pytest.fixture -def mock_cloud_client( - mock_device_info: LaMarzoccoDeviceInfo, -) -> Generator[MagicMock]: +@pytest.fixture(autouse=True) +def mock_cloud_client() -> Generator[MagicMock]: """Return a mocked LM cloud client.""" with ( patch( @@ -117,54 +71,50 @@ def mock_cloud_client( ), ): client = cloud_client.return_value - client.get_customer_fleet.return_value = { - mock_device_info.serial_number: mock_device_info - } + client.list_things.return_value = [ + Thing.from_dict(load_json_object_fixture("thing.json", DOMAIN)) + ] + client.get_thing_settings.return_value = ThingSettings.from_dict( + load_json_object_fixture("settings.json", DOMAIN) + ) yield client @pytest.fixture -def mock_lamarzocco(device_fixture: MachineModel) -> Generator[MagicMock]: +def mock_lamarzocco(device_fixture: ModelName) -> Generator[MagicMock]: """Return a mocked LM client.""" - model = device_fixture - serial_number = SERIAL_DICT[model] - - dummy_machine = LaMarzoccoMachine( - model=model, - serial_number=serial_number, - name=serial_number, - ) - if device_fixture == MachineModel.LINEA_MINI: + if device_fixture == ModelName.LINEA_MINI: config = load_json_object_fixture("config_mini.json", DOMAIN) + elif device_fixture == ModelName.LINEA_MICRA: + config = load_json_object_fixture("config_micra.json", DOMAIN) else: - config = load_json_object_fixture("config.json", DOMAIN) - statistics = json.loads(load_fixture("statistics.json", DOMAIN)) - - dummy_machine.parse_config(config) - dummy_machine.parse_statistics(statistics) + config = load_json_object_fixture("config_gs3.json", DOMAIN) + schedule = load_json_object_fixture("schedule.json", DOMAIN) + settings = load_json_object_fixture("settings.json", DOMAIN) + statistics = load_json_object_fixture("statistics.json", DOMAIN) with ( patch( "homeassistant.components.lamarzocco.LaMarzoccoMachine", autospec=True, - ) as lamarzocco_mock, + ) as machine_mock_init, ): - lamarzocco = lamarzocco_mock.return_value + machine_mock = machine_mock_init.return_value - lamarzocco.name = dummy_machine.name - lamarzocco.model = dummy_machine.model - lamarzocco.serial_number = dummy_machine.serial_number - lamarzocco.full_model_name = dummy_machine.full_model_name - lamarzocco.config = dummy_machine.config - lamarzocco.statistics = dummy_machine.statistics - lamarzocco.firmware = dummy_machine.firmware - lamarzocco.steam_level = SteamLevel.LEVEL_1 - - lamarzocco.firmware[FirmwareType.GATEWAY].latest_version = "v3.5-rc3" - lamarzocco.firmware[FirmwareType.MACHINE].latest_version = "1.55" - - yield lamarzocco + machine_mock.serial_number = SERIAL_DICT[device_fixture] + machine_mock.dashboard = ThingDashboardConfig.from_dict(config) + machine_mock.schedule = ThingSchedulingSettings.from_dict(schedule) + machine_mock.settings = ThingSettings.from_dict(settings) + machine_mock.statistics = ThingStatistics.from_dict(statistics) + machine_mock.dashboard.model_name = device_fixture + machine_mock.to_dict.return_value = { + "serial_number": machine_mock.serial_number, + "dashboard": machine_mock.dashboard.to_dict(), + "schedule": machine_mock.schedule.to_dict(), + "settings": machine_mock.settings.to_dict(), + } + yield machine_mock @pytest.fixture(autouse=True) diff --git a/tests/components/lamarzocco/fixtures/config.json b/tests/components/lamarzocco/fixtures/config.json deleted file mode 100644 index 5aac86dde97..00000000000 --- a/tests/components/lamarzocco/fixtures/config.json +++ /dev/null @@ -1,198 +0,0 @@ -{ - "version": "v1", - "preinfusionModesAvailable": ["ByDoseType"], - "machineCapabilities": [ - { - "family": "GS3AV", - "groupsNumber": 1, - "coffeeBoilersNumber": 1, - "hasCupWarmer": false, - "steamBoilersNumber": 1, - "teaDosesNumber": 1, - "machineModes": ["BrewingMode", "StandBy"], - "schedulingType": "weeklyScheduling" - } - ], - "machine_sn": "Sn01239157", - "machine_hw": "2", - "isPlumbedIn": true, - "isBackFlushEnabled": false, - "standByTime": 0, - "smartStandBy": { - "enabled": true, - "minutes": 10, - "mode": "LastBrewing" - }, - "tankStatus": true, - "groupCapabilities": [ - { - "capabilities": { - "groupType": "AV_Group", - "groupNumber": "Group1", - "boilerId": "CoffeeBoiler1", - "hasScale": false, - "hasFlowmeter": true, - "numberOfDoses": 4 - }, - "doses": [ - { - "groupNumber": "Group1", - "doseIndex": "DoseA", - "doseType": "PulsesType", - "stopTarget": 135 - }, - { - "groupNumber": "Group1", - "doseIndex": "DoseB", - "doseType": "PulsesType", - "stopTarget": 97 - }, - { - "groupNumber": "Group1", - "doseIndex": "DoseC", - "doseType": "PulsesType", - "stopTarget": 108 - }, - { - "groupNumber": "Group1", - "doseIndex": "DoseD", - "doseType": "PulsesType", - "stopTarget": 121 - } - ], - "doseMode": { - "groupNumber": "Group1", - "brewingType": "PulsesType" - } - } - ], - "machineMode": "BrewingMode", - "teaDoses": { - "DoseA": { - "doseIndex": "DoseA", - "stopTarget": 8 - } - }, - "boilers": [ - { - "id": "SteamBoiler", - "isEnabled": true, - "target": 123.90000152587891, - "current": 123.80000305175781 - }, - { - "id": "CoffeeBoiler1", - "isEnabled": true, - "target": 95, - "current": 96.5 - } - ], - "boilerTargetTemperature": { - "SteamBoiler": 123.90000152587891, - "CoffeeBoiler1": 95 - }, - "preinfusionMode": { - "Group1": { - "groupNumber": "Group1", - "preinfusionStyle": "PreinfusionByDoseType" - } - }, - "preinfusionSettings": { - "mode": "TypeB", - "Group1": [ - { - "mode": "TypeA", - "groupNumber": "Group1", - "doseType": "DoseA", - "preWetTime": 0.5, - "preWetHoldTime": 1 - }, - { - "mode": "TypeB", - "groupNumber": "Group1", - "doseType": "DoseA", - "preWetTime": 0, - "preWetHoldTime": 4 - }, - { - "mode": "TypeA", - "groupNumber": "Group1", - "doseType": "DoseB", - "preWetTime": 0.5, - "preWetHoldTime": 1 - }, - { - "mode": "TypeB", - "groupNumber": "Group1", - "doseType": "DoseB", - "preWetTime": 0, - "preWetHoldTime": 4 - }, - { - "mode": "TypeA", - "groupNumber": "Group1", - "doseType": "DoseC", - "preWetTime": 3.3, - "preWetHoldTime": 3.3 - }, - { - "mode": "TypeB", - "groupNumber": "Group1", - "doseType": "DoseC", - "preWetTime": 0, - "preWetHoldTime": 4 - }, - { - "mode": "TypeA", - "groupNumber": "Group1", - "doseType": "DoseD", - "preWetTime": 2, - "preWetHoldTime": 2 - }, - { - "mode": "TypeB", - "groupNumber": "Group1", - "doseType": "DoseD", - "preWetTime": 0, - "preWetHoldTime": 4 - } - ] - }, - "wakeUpSleepEntries": [ - { - "days": [ - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday" - ], - "enabled": true, - "id": "Os2OswX", - "steam": true, - "timeOff": "24:0", - "timeOn": "22:0" - }, - { - "days": ["sunday"], - "enabled": true, - "id": "aXFz5bJ", - "steam": true, - "timeOff": "7:30", - "timeOn": "7:0" - } - ], - "clock": "1901-07-08T10:29:00", - "firmwareVersions": [ - { - "name": "machine_firmware", - "fw_version": "1.40" - }, - { - "name": "gateway_firmware", - "fw_version": "v3.1-rc4" - } - ] -} diff --git a/tests/components/lamarzocco/fixtures/config_gs3.json b/tests/components/lamarzocco/fixtures/config_gs3.json new file mode 100644 index 00000000000..8958bb90fc4 --- /dev/null +++ b/tests/components/lamarzocco/fixtures/config_gs3.json @@ -0,0 +1,377 @@ +{ + "serialNumber": "GS012345", + "type": "CoffeeMachine", + "name": "GS012345", + "location": "HOME", + "modelCode": "GS3AV", + "modelName": "GS3AV", + "connected": true, + "connectionDate": 1742489087479, + "offlineMode": false, + "requireFirmwareUpdate": false, + "availableFirmwareUpdate": false, + "coffeeStation": null, + "imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/gs3av/gs3av-1.png", + "bleAuthToken": null, + "widgets": [ + { + "code": "CMMachineStatus", + "index": 1, + "output": { + "status": "PoweredOn", + "availableModes": ["BrewingMode", "StandBy"], + "mode": "BrewingMode", + "nextStatus": { + "status": "StandBy", + "startTime": 1742857195332 + }, + "brewingStartTime": null + }, + "tutorialUrl": null + }, + { + "code": "CMCoffeeBoiler", + "index": 1, + "output": { + "status": "Ready", + "enabled": true, + "enabledSupported": false, + "targetTemperature": 95.0, + "targetTemperatureMin": 80, + "targetTemperatureMax": 110, + "targetTemperatureStep": 0.1, + "readyStartTime": null + }, + "tutorialUrl": null + }, + { + "code": "CMSteamBoilerTemperature", + "index": 1, + "output": { + "status": "Off", + "enabled": true, + "enabledSupported": true, + "targetTemperature": 123.9, + "targetTemperatureSupported": true, + "targetTemperatureMin": 95, + "targetTemperatureMax": 140, + "targetTemperatureStep": 0.1, + "readyStartTime": null + }, + "tutorialUrl": null + }, + { + "code": "CMGroupDoses", + "index": 1, + "output": { + "mirrorWithGroup1Supported": false, + "mirrorWithGroup1": null, + "mirrorWithGroup1NotEffective": false, + "availableModes": ["PulsesType"], + "mode": "PulsesType", + "profile": null, + "doses": { + "PulsesType": [ + { + "doseIndex": "DoseA", + "dose": 126.0, + "doseMin": 0, + "doseMax": 9999, + "doseStep": 1 + }, + { + "doseIndex": "DoseB", + "dose": 126.0, + "doseMin": 0, + "doseMax": 9999, + "doseStep": 1 + }, + { + "doseIndex": "DoseC", + "dose": 160.0, + "doseMin": 0, + "doseMax": 9999, + "doseStep": 1 + }, + { + "doseIndex": "DoseD", + "dose": 77.0, + "doseMin": 0, + "doseMax": 9999, + "doseStep": 1 + } + ] + }, + "continuousDoseSupported": false, + "continuousDose": null, + "brewingPressureSupported": false, + "brewingPressure": null + }, + "tutorialUrl": null + }, + { + "code": "CMPreBrewing", + "index": 1, + "output": { + "availableModes": ["PreBrewing", "PreInfusion", "Disabled"], + "mode": "PreInfusion", + "times": { + "PreBrewing": [ + { + "doseIndex": "DoseA", + "seconds": { + "In": 0.5, + "Out": 1.0 + }, + "secondsMin": { + "In": 0, + "Out": 0 + }, + "secondsMax": { + "In": 10, + "Out": 10 + }, + "secondsStep": { + "In": 0.1, + "Out": 0.1 + } + }, + { + "doseIndex": "DoseB", + "seconds": { + "In": 0.5, + "Out": 1.0 + }, + "secondsMin": { + "In": 0, + "Out": 0 + }, + "secondsMax": { + "In": 10, + "Out": 10 + }, + "secondsStep": { + "In": 0.1, + "Out": 0.1 + } + }, + { + "doseIndex": "DoseC", + "seconds": { + "In": 3.3, + "Out": 3.3 + }, + "secondsMin": { + "In": 0, + "Out": 0 + }, + "secondsMax": { + "In": 10, + "Out": 10 + }, + "secondsStep": { + "In": 0.1, + "Out": 0.1 + } + }, + { + "doseIndex": "DoseD", + "seconds": { + "In": 2.0, + "Out": 2.0 + }, + "secondsMin": { + "In": 0, + "Out": 0 + }, + "secondsMax": { + "In": 10, + "Out": 10 + }, + "secondsStep": { + "In": 0.1, + "Out": 0.1 + } + } + ], + "PreInfusion": [ + { + "doseIndex": "DoseA", + "seconds": { + "In": 0.0, + "Out": 4.0 + }, + "secondsMin": { + "In": 0, + "Out": 0 + }, + "secondsMax": { + "In": 25, + "Out": 25 + }, + "secondsStep": { + "In": 0.1, + "Out": 0.1 + } + }, + { + "doseIndex": "DoseB", + "seconds": { + "In": 0.0, + "Out": 4.0 + }, + "secondsMin": { + "In": 0, + "Out": 0 + }, + "secondsMax": { + "In": 25, + "Out": 25 + }, + "secondsStep": { + "In": 0.1, + "Out": 0.1 + } + }, + { + "doseIndex": "DoseC", + "seconds": { + "In": 0.0, + "Out": 4.0 + }, + "secondsMin": { + "In": 0, + "Out": 0 + }, + "secondsMax": { + "In": 25, + "Out": 25 + }, + "secondsStep": { + "In": 0.1, + "Out": 0.1 + } + }, + { + "doseIndex": "DoseD", + "seconds": { + "In": 0.0, + "Out": 4.0 + }, + "secondsMin": { + "In": 0, + "Out": 0 + }, + "secondsMax": { + "In": 25, + "Out": 25 + }, + "secondsStep": { + "In": 0.1, + "Out": 0.1 + } + } + ] + }, + "doseIndexSupported": true + }, + "tutorialUrl": "https://www.lamarzocco.com/it/en/app/support/brewing-features/#gs3-av-linea-micra-linea-mini-home" + }, + { + "code": "CMHotWaterDose", + "index": 1, + "output": { + "enabledSupported": false, + "enabled": true, + "doses": [ + { + "doseIndex": "DoseA", + "dose": 8.0, + "doseMin": 0, + "doseMax": 90, + "doseStep": 1 + } + ] + }, + "tutorialUrl": null + }, + { + "code": "CMBackFlush", + "index": 1, + "output": { + "lastCleaningStartTime": 1743236747166, + "status": "Off" + }, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/cleaning-and-backflush/#gs3-av" + } + ], + "invalidWidgets": [ + { + "code": "CMMachineGroupStatus", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMSteamBoilerLevel", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMPreExtraction", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMPreInfusionEnable", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMPreInfusion", + "index": 1, + "output": null, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/brewing-features/#commercial" + }, + { + "code": "CMBrewByWeightDoses", + "index": 1, + "output": null, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/brew-by-weight" + }, + { + "code": "CMCupWarmer", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMAutoFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMRinseFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMSteamFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMNoWater", + "index": 1, + "output": null, + "tutorialUrl": null + } + ], + "runningCommands": [] +} diff --git a/tests/components/lamarzocco/fixtures/config_micra.json b/tests/components/lamarzocco/fixtures/config_micra.json new file mode 100644 index 00000000000..64345c93682 --- /dev/null +++ b/tests/components/lamarzocco/fixtures/config_micra.json @@ -0,0 +1,237 @@ +{ + "serialNumber": "MR012345", + "type": "CoffeeMachine", + "name": "MR012345", + "location": null, + "modelCode": "LINEAMICRA", + "modelName": "LINEA MICRA", + "connected": true, + "connectionDate": 1742526019892, + "offlineMode": false, + "requireFirmwareUpdate": false, + "availableFirmwareUpdate": false, + "coffeeStation": null, + "imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png", + "bleAuthToken": null, + "widgets": [ + { + "code": "CMMachineStatus", + "index": 1, + "output": { + "status": "StandBy", + "availableModes": ["BrewingMode", "StandBy"], + "mode": "StandBy", + "nextStatus": null, + "brewingStartTime": null + }, + "tutorialUrl": null + }, + { + "code": "CMCoffeeBoiler", + "index": 1, + "output": { + "status": "StandBy", + "enabled": true, + "enabledSupported": false, + "targetTemperature": 94.0, + "targetTemperatureMin": 80, + "targetTemperatureMax": 100, + "targetTemperatureStep": 0.1, + "readyStartTime": null + }, + "tutorialUrl": null + }, + { + "code": "CMSteamBoilerLevel", + "index": 1, + "output": { + "status": "StandBy", + "enabled": true, + "enabledSupported": true, + "targetLevel": "Level3", + "targetLevelSupported": true, + "readyStartTime": null + }, + "tutorialUrl": null + }, + { + "code": "CMPreExtraction", + "index": 1, + "output": { + "availableModes": ["PreBrewing", "PreInfusion", "Disabled"], + "mode": "PreInfusion", + "times": { + "In": { + "seconds": 0.0, + "secondsMin": { + "PreBrewing": 2, + "PreInfusion": 2 + }, + "secondsMax": { + "PreBrewing": 9, + "PreInfusion": 9 + }, + "secondsStep": { + "PreBrewing": 0.1, + "PreInfusion": 0.1 + } + }, + "Out": { + "seconds": 4.0, + "secondsMin": { + "PreBrewing": 1, + "PreInfusion": 1 + }, + "secondsMax": { + "PreBrewing": 9, + "PreInfusion": 25 + }, + "secondsStep": { + "PreBrewing": 0.1, + "PreInfusion": 0.1 + } + } + } + }, + "tutorialUrl": null + }, + { + "code": "CMPreBrewing", + "index": 1, + "output": { + "availableModes": ["PreBrewing", "PreInfusion", "Disabled"], + "mode": "PreInfusion", + "times": { + "PreInfusion": [ + { + "doseIndex": "ByGroup", + "seconds": { + "Out": 4.0, + "In": 0.0 + }, + "secondsMin": { + "Out": 1, + "In": 1 + }, + "secondsMax": { + "Out": 25, + "In": 25 + }, + "secondsStep": { + "Out": 0.1, + "In": 0.1 + } + } + ], + "PreBrewing": [ + { + "doseIndex": "ByGroup", + "seconds": { + "Out": 5.0, + "In": 5.0 + }, + "secondsMin": { + "Out": 1, + "In": 1 + }, + "secondsMax": { + "Out": 9, + "In": 9 + }, + "secondsStep": { + "Out": 0.1, + "In": 0.1 + } + } + ] + }, + "doseIndexSupported": false + }, + "tutorialUrl": "https://www.lamarzocco.com/it/en/app/support/brewing-features/#gs3-av-linea-micra-linea-mini-home" + }, + { + "code": "CMBackFlush", + "index": 1, + "output": { + "lastCleaningStartTime": null, + "status": "Off" + }, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/cleaning-and-backflush/#linea-micra" + } + ], + "invalidWidgets": [ + { + "code": "CMMachineGroupStatus", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMSteamBoilerTemperature", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMGroupDoses", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMPreInfusionEnable", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMPreInfusion", + "index": 1, + "output": null, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/brewing-features/#commercial" + }, + { + "code": "CMBrewByWeightDoses", + "index": 1, + "output": null, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/brew-by-weight" + }, + { + "code": "CMCupWarmer", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMHotWaterDose", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMAutoFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMRinseFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMSteamFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMNoWater", + "index": 1, + "output": null, + "tutorialUrl": null + } + ], + "runningCommands": [] +} diff --git a/tests/components/lamarzocco/fixtures/config_mini.json b/tests/components/lamarzocco/fixtures/config_mini.json index a726d715a6f..a5a285800e9 100644 --- a/tests/components/lamarzocco/fixtures/config_mini.json +++ b/tests/components/lamarzocco/fixtures/config_mini.json @@ -1,124 +1,284 @@ { - "version": "v1", - "preinfusionModesAvailable": ["ByDoseType"], - "machineCapabilities": [ - { - "family": "LINEA", - "groupsNumber": 1, - "coffeeBoilersNumber": 1, - "hasCupWarmer": false, - "steamBoilersNumber": 1, - "teaDosesNumber": 1, - "machineModes": ["BrewingMode", "StandBy"], - "schedulingType": "smartWakeUpSleep" - } - ], - "machine_sn": "Sn01239157", - "machine_hw": "0", - "isPlumbedIn": false, - "isBackFlushEnabled": false, - "standByTime": 0, - "tankStatus": true, - "settings": [], - "recipes": [ - { - "id": "Recipe1", - "dose_mode": "Mass", - "recipe_doses": [ - { "id": "A", "target": 32 }, - { "id": "B", "target": 45 } - ] - } - ], - "recipeAssignment": [ - { - "dose_index": "DoseA", - "recipe_id": "Recipe1", - "recipe_dose": "A", - "group": "Group1" - } - ], - "groupCapabilities": [ - { - "capabilities": { - "groupType": "AV_Group", - "groupNumber": "Group1", - "boilerId": "CoffeeBoiler1", - "hasScale": false, - "hasFlowmeter": false, - "numberOfDoses": 1 - }, - "doses": [ - { - "groupNumber": "Group1", - "doseIndex": "DoseA", - "doseType": "MassType", - "stopTarget": 32 - } - ], - "doseMode": { "groupNumber": "Group1", "brewingType": "ManualType" } - } - ], - "machineMode": "StandBy", - "teaDoses": { "DoseA": { "doseIndex": "DoseA", "stopTarget": 0 } }, - "scale": { - "connected": true, - "address": "44:b7:d0:74:5f:90", - "name": "LMZ-123A45", - "battery": 64 - }, - "boilers": [ - { "id": "SteamBoiler", "isEnabled": false, "target": 0, "current": 0 }, - { "id": "CoffeeBoiler1", "isEnabled": true, "target": 89, "current": 42 } - ], - "boilerTargetTemperature": { "SteamBoiler": 0, "CoffeeBoiler1": 89 }, - "preinfusionMode": { - "Group1": { - "groupNumber": "Group1", - "preinfusionStyle": "PreinfusionByDoseType" - } - }, - "preinfusionSettings": { - "mode": "TypeB", - "Group1": [ + "serialNumber": "LM012345", + "type": "CoffeeMachine", + "name": "LM012345", + "location": null, + "modelCode": "LINEAMINI", + "modelName": "LINEA MINI", + "connected": true, + "connectionDate": 1742683649814, + "offlineMode": false, + "requireFirmwareUpdate": false, + "availableFirmwareUpdate": true, + "coffeeStation": { + "id": "a59cd870-dc75-428f-b73e-e5a247c6db73", + "name": "My coffee station", + "coffeeMachine": { + "serialNumber": "LM012345", + "type": "CoffeeMachine", + "name": null, + "location": null, + "modelCode": "LINEAMINI", + "modelName": "LINEA MINI", + "connected": true, + "connectionDate": 1742683649814, + "offlineMode": false, + "requireFirmwareUpdate": false, + "availableFirmwareUpdate": true, + "imageUrl": "https://lion.lamarzocco.io/img/thing-model/list/lineamini/lineamini-1-c-nero_op.png", + "bleAuthToken": null + }, + "grinders": [], + "accessories": [ { - "mode": "TypeA", - "groupNumber": "Group1", - "doseType": "Continuous", - "preWetTime": 2, - "preWetHoldTime": 3 - }, - { - "mode": "TypeB", - "groupNumber": "Group1", - "doseType": "Continuous", - "preWetTime": 0, - "preWetHoldTime": 3 + "type": "ScaleAcaiaLunar", + "name": "LMZ-123A12", + "connected": false, + "batteryLevel": null, + "imageUrl": null } ] }, - "wakeUpSleepEntries": [ + "imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/lineamini/lineamini-1-c-nero_op.png", + "bleAuthToken": null, + "widgets": [ { - "id": "T6aLl42", - "days": [ - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday" - ], - "steam": false, - "enabled": false, - "timeOn": "24:0", - "timeOff": "24:0" + "code": "CMMachineStatus", + "index": 1, + "output": { + "status": "StandBy", + "availableModes": ["BrewingMode", "StandBy"], + "mode": "StandBy", + "nextStatus": null, + "brewingStartTime": null + }, + "tutorialUrl": null + }, + { + "code": "CMCoffeeBoiler", + "index": 1, + "output": { + "status": "StandBy", + "enabled": true, + "enabledSupported": false, + "targetTemperature": 90.0, + "targetTemperatureMin": 80, + "targetTemperatureMax": 100, + "targetTemperatureStep": 0.1, + "readyStartTime": null + }, + "tutorialUrl": null + }, + { + "code": "CMSteamBoilerTemperature", + "index": 1, + "output": { + "status": "Off", + "enabled": false, + "enabledSupported": true, + "targetTemperature": 0.0, + "targetTemperatureSupported": false, + "targetTemperatureMin": 95, + "targetTemperatureMax": 140, + "targetTemperatureStep": 0.1, + "readyStartTime": null + }, + "tutorialUrl": null + }, + { + "code": "CMPreExtraction", + "index": 1, + "output": { + "availableModes": ["PreBrewing", "Disabled"], + "mode": "Disabled", + "times": { + "In": { + "seconds": 2.0, + "secondsMin": { + "PreBrewing": 2, + "PreInfusion": 2 + }, + "secondsMax": { + "PreBrewing": 9, + "PreInfusion": 9 + }, + "secondsStep": { + "PreBrewing": 0.1, + "PreInfusion": 0.1 + } + }, + "Out": { + "seconds": 3.0, + "secondsMin": { + "PreBrewing": 1, + "PreInfusion": 1 + }, + "secondsMax": { + "PreBrewing": 9, + "PreInfusion": 25 + }, + "secondsStep": { + "PreBrewing": 0.1, + "PreInfusion": 0.1 + } + } + } + }, + "tutorialUrl": null + }, + { + "code": "CMPreBrewing", + "index": 1, + "output": { + "availableModes": ["PreBrewing", "Disabled"], + "mode": "Disabled", + "times": { + "PreBrewing": [ + { + "doseIndex": "ByGroup", + "seconds": { + "Out": 3.0, + "In": 2.0 + }, + "secondsMin": { + "Out": 1, + "In": 1 + }, + "secondsMax": { + "Out": 9, + "In": 9 + }, + "secondsStep": { + "Out": 0.1, + "In": 0.1 + } + } + ] + }, + "doseIndexSupported": false + }, + "tutorialUrl": "https://www.lamarzocco.com/it/en/app/support/brewing-features/#gs3-av-linea-micra-linea-mini-home" + }, + { + "code": "CMBrewByWeightDoses", + "index": 1, + "output": { + "scaleConnected": false, + "availableModes": ["Continuous"], + "mode": "Continuous", + "doses": { + "Dose1": { + "dose": 34.5, + "doseMin": 5, + "doseMax": 100, + "doseStep": 0.1 + }, + "Dose2": { + "dose": 17.5, + "doseMin": 5, + "doseMax": 100, + "doseStep": 0.1 + } + } + }, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/brew-by-weight" + }, + { + "code": "CMBackFlush", + "index": 1, + "output": { + "lastCleaningStartTime": 1742731776135, + "status": "Off" + }, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/cleaning-and-backflush/#linea-mini" + }, + { + "code": "ThingScale", + "index": 2, + "output": { + "name": "LMZ-123A12", + "connected": false, + "batteryLevel": 0.0, + "calibrationRequired": false + }, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/training-scale-location/#linea-mini" } ], - "smartStandBy": { "mode": "LastBrewing", "minutes": 10, "enabled": true }, - "clock": "2024-08-31T14:47:45", - "firmwareVersions": [ - { "name": "machine_firmware", "fw_version": "2.12" }, - { "name": "gateway_firmware", "fw_version": "v3.6-rc4" } - ] + "invalidWidgets": [ + { + "code": "CMMachineGroupStatus", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMSteamBoilerLevel", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMGroupDoses", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMPreInfusionEnable", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMPreInfusion", + "index": 1, + "output": null, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/brewing-features/#commercial" + }, + { + "code": "CMCupWarmer", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMHotWaterDose", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMAutoFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMRinseFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMSteamFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMNoWater", + "index": 1, + "output": { + "allarm": false + }, + "tutorialUrl": null + }, + { + "code": "ThingScale", + "index": 1, + "output": null, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/training-scale-location/#linea-mini" + } + ], + "runningCommands": [] } diff --git a/tests/components/lamarzocco/fixtures/schedule.json b/tests/components/lamarzocco/fixtures/schedule.json new file mode 100644 index 00000000000..1767503f5b9 --- /dev/null +++ b/tests/components/lamarzocco/fixtures/schedule.json @@ -0,0 +1,61 @@ +{ + "serialNumber": "MR123456", + "type": "CoffeeMachine", + "name": "MR123456", + "location": null, + "modelCode": "LINEAMICRA", + "modelName": "LINEA MICRA", + "connected": true, + "connectionDate": 1742526019892, + "offlineMode": false, + "requireFirmwareUpdate": false, + "availableFirmwareUpdate": false, + "coffeeStation": null, + "imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png", + "bleAuthToken": null, + "smartWakeUpSleepSupported": true, + "smartWakeUpSleep": { + "smartStandByEnabled": true, + "smartStandByMinutes": 10, + "smartStandByMinutesMin": 1, + "smartStandByMinutesMax": 30, + "smartStandByMinutesStep": 1, + "smartStandByAfter": "PowerOn", + "schedules": [ + { + "id": "Os2OswX", + "enabled": true, + "onTimeMinutes": 1320, + "offTimeMinutes": 1440, + "days": [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday" + ], + "steamBoiler": true + }, + { + "id": "aXFz5bJ", + "enabled": true, + "onTimeMinutes": 420, + "offTimeMinutes": 450, + "days": ["Sunday"], + "steamBoiler": false + } + ] + }, + "smartWakeUpSleepTutorialUrl": "https://www.lamarzocco.com/it/en/app/support/scheduling/#gs3-linea-micra-linea-mini-home", + "weeklySupported": false, + "weekly": null, + "weeklyTutorialUrl": "https://www.lamarzocco.com/it/en/app/support/scheduling/#linea-classic-s", + "autoOnOffSupported": false, + "autoOnOff": null, + "autoOnOffTutorialUrl": "https://www.lamarzocco.com/it/en/app/support/scheduling/#gb5-s-x-kb90-linea-pb-pbx-strada-s-x-commercial", + "autoStandBySupported": false, + "autoStandBy": null, + "autoStandByTutorialUrl": null +} diff --git a/tests/components/lamarzocco/fixtures/settings.json b/tests/components/lamarzocco/fixtures/settings.json new file mode 100644 index 00000000000..a2bd27febb2 --- /dev/null +++ b/tests/components/lamarzocco/fixtures/settings.json @@ -0,0 +1,50 @@ +{ + "serialNumber": "MR123456", + "type": "CoffeeMachine", + "name": "MR123456", + "location": null, + "modelCode": "LINEAMICRA", + "modelName": "LINEA MICRA", + "connected": true, + "connectionDate": 1742526019892, + "offlineMode": false, + "requireFirmwareUpdate": false, + "availableFirmwareUpdate": false, + "coffeeStation": null, + "imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png", + "bleAuthToken": null, + "actualFirmwares": [ + { + "type": "Gateway", + "buildVersion": "v5.0.9", + "changeLog": "What’s new in this version:\n\n* New La Marzocco compatibility\n* Improved connectivity\n* Improved pairing process\n* Improved statistics\n* Boilers heating time\n* Last backflush date (GS3 MP excluded)\n* Automatic gateway updates option", + "thingModelCode": "LineaMicra", + "status": "ToUpdate", + "availableUpdate": { + "type": "Gateway", + "buildVersion": "v5.0.10", + "changeLog": "What’s new in this version:\n\n* fixed an issue that could cause the machine powers up outside scheduled time\n* minor improvements", + "thingModelCode": "LineaMicra" + } + }, + { + "type": "Machine", + "buildVersion": "v1.17", + "changeLog": null, + "thingModelCode": "LineaMicra", + "status": "Updated", + "availableUpdate": null + } + ], + "wifiSsid": "MyWifi", + "wifiRssi": -51, + "plumbInSupported": true, + "isPlumbedIn": true, + "cropsterSupported": false, + "cropsterActive": null, + "hemroSupported": false, + "hemroActive": null, + "factoryResetSupported": true, + "autoUpdateSupported": true, + "autoUpdate": false +} diff --git a/tests/components/lamarzocco/fixtures/statistics.json b/tests/components/lamarzocco/fixtures/statistics.json index c82d02cc7c1..0c333457d69 100644 --- a/tests/components/lamarzocco/fixtures/statistics.json +++ b/tests/components/lamarzocco/fixtures/statistics.json @@ -1,26 +1,183 @@ -[ - { - "count": 1047, - "coffeeType": 0 - }, - { - "count": 560, - "coffeeType": 1 - }, - { - "count": 468, - "coffeeType": 2 - }, - { - "count": 312, - "coffeeType": 3 - }, - { - "count": 2252, - "coffeeType": 4 - }, - { - "coffeeType": -1, - "count": 1740 - } -] +{ + "serialNumber": "MR123456", + "type": "CoffeeMachine", + "name": "MR123456", + "location": null, + "modelCode": "LINEAMICRA", + "modelName": "LINEA MICRA", + "connected": true, + "connectionDate": 1742526019892, + "offlineMode": false, + "requireFirmwareUpdate": false, + "availableFirmwareUpdate": false, + "coffeeStation": null, + "imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png", + "bleAuthToken": null, + "firmwares": null, + "selectedWidgetCodes": ["COFFEE_AND_FLUSH_TREND", "LAST_COFFEE"], + "allWidgetCodes": ["LAST_COFFEE", "COFFEE_AND_FLUSH_TREND"], + "selectedWidgets": [ + { + "code": "COFFEE_AND_FLUSH_TREND", + "index": 1, + "output": { + "days": 7, + "timezone": "Europe/Berlin", + "coffees": [ + { "timestamp": 1741993200000, "value": 2 }, + { "timestamp": 1742079600000, "value": 2 }, + { "timestamp": 1742166000000, "value": 2 }, + { "timestamp": 1742252400000, "value": 2 }, + { "timestamp": 1742338800000, "value": 4 }, + { "timestamp": 1742425200000, "value": 3 }, + { "timestamp": 1742511600000, "value": 1 } + ], + "flushes": [ + { "timestamp": 1741993200000, "value": 1 }, + { "timestamp": 1742079600000, "value": 1 }, + { "timestamp": 1742166000000, "value": 0 }, + { "timestamp": 1742252400000, "value": 0 }, + { "timestamp": 1742338800000, "value": 4 }, + { "timestamp": 1742425200000, "value": 2 }, + { "timestamp": 1742511600000, "value": 1 } + ] + } + }, + { + "code": "LAST_COFFEE", + "index": 1, + "output": { + "lastCoffees": [ + { + "time": 1742535679203, + "extractionSeconds": 30.44, + "doseMode": "Continuous", + "doseIndex": "Continuous", + "doseValue": null, + "doseValueNumerator": null + }, + { + "time": 1742489827722, + "extractionSeconds": 10.8, + "doseMode": "Continuous", + "doseIndex": "Continuous", + "doseValue": null, + "doseValueNumerator": null + }, + { + "time": 1742448826919, + "extractionSeconds": 12.457, + "doseMode": "Continuous", + "doseIndex": "Continuous", + "doseValue": null, + "doseValueNumerator": null + }, + { + "time": 1742448702812, + "extractionSeconds": 23.504, + "doseMode": "Continuous", + "doseIndex": "Continuous", + "doseValue": null, + "doseValueNumerator": null + }, + { + "time": 1742396255439, + "extractionSeconds": 16.031, + "doseMode": "Continuous", + "doseIndex": "Continuous", + "doseValue": null, + "doseValueNumerator": null + }, + { + "time": 1742396142154, + "extractionSeconds": 27.413, + "doseMode": "Continuous", + "doseIndex": "Continuous", + "doseValue": null, + "doseValueNumerator": null + }, + { + "time": 1742364379903, + "extractionSeconds": 14.182, + "doseMode": "Continuous", + "doseIndex": "Continuous", + "doseValue": null, + "doseValueNumerator": null + }, + { + "time": 1742364235304, + "extractionSeconds": 23.228, + "doseMode": "Continuous", + "doseIndex": "Continuous", + "doseValue": null, + "doseValueNumerator": null + }, + { + "time": 1742277098548, + "extractionSeconds": 12.98, + "doseMode": "PulsesType", + "doseIndex": "DoseA", + "doseValue": 0, + "doseValueNumerator": null + }, + { + "time": 1742277006774, + "extractionSeconds": 26.99, + "doseMode": "PulsesType", + "doseIndex": "DoseA", + "doseValue": 0, + "doseValueNumerator": null + }, + { + "time": 1742190219197, + "extractionSeconds": 11.069, + "doseMode": "PulsesType", + "doseIndex": "DoseA", + "doseValue": 0, + "doseValueNumerator": null + }, + { + "time": 1742190123385, + "extractionSeconds": 35.472, + "doseMode": "PulsesType", + "doseIndex": "DoseA", + "doseValue": 0, + "doseValueNumerator": null + }, + { + "time": 1742106228119, + "extractionSeconds": 11.494, + "doseMode": "PulsesType", + "doseIndex": "DoseA", + "doseValue": 0, + "doseValueNumerator": null + }, + { + "time": 1742106147433, + "extractionSeconds": 39.915, + "doseMode": "PulsesType", + "doseIndex": "DoseA", + "doseValue": 0, + "doseValueNumerator": null + }, + { + "time": 1742017890205, + "extractionSeconds": 13.891, + "doseMode": "PulsesType", + "doseIndex": "DoseA", + "doseValue": 0, + "doseValueNumerator": null + } + ] + } + }, + { + "code": "COFFEE_AND_FLUSH_COUNTER", + "index": 1, + "output": { + "totalCoffee": 1620, + "totalFlush": 1366 + } + } + ] +} diff --git a/tests/components/lamarzocco/fixtures/thing.json b/tests/components/lamarzocco/fixtures/thing.json new file mode 100644 index 00000000000..4265ad9ed8d --- /dev/null +++ b/tests/components/lamarzocco/fixtures/thing.json @@ -0,0 +1,16 @@ +{ + "serialNumber": "GS012345", + "type": "CoffeeMachine", + "name": "GS012345", + "location": "HOME", + "modelCode": "GS3AV", + "modelName": "GS3AV", + "connected": true, + "connectionDate": 1742489087479, + "offlineMode": false, + "requireFirmwareUpdate": false, + "availableFirmwareUpdate": false, + "coffeeStation": null, + "imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/gs3av/gs3av-1.png", + "bleAuthToken": null +} diff --git a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr index 6cd4e8cd5ae..0e772fb9653 100644 --- a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr @@ -143,21 +143,7 @@ 'state': 'off', }) # --- -# name: test_scale_connectivity[Linea Mini] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'LMZ-123A45 Connectivity', - }), - 'context': , - 'entity_id': 'binary_sensor.lmz_123a45_connectivity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_scale_connectivity[Linea Mini].1 +# name: test_binary_sensors[binary_sensor.gs012345_websocket_connected-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -170,7 +156,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.lmz_123a45_connectivity', + 'entity_id': 'binary_sensor.gs012345_websocket_connected', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -182,12 +168,26 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Connectivity', + 'original_name': 'WebSocket connected', 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'LM012345_connected', + 'translation_key': 'websocket_connected', + 'unique_id': 'GS012345_websocket_connected', 'unit_of_measurement': None, }) # --- +# name: test_binary_sensors[binary_sensor.gs012345_websocket_connected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'GS012345 WebSocket connected', + }), + 'context': , + 'entity_id': 'binary_sensor.gs012345_websocket_connected', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr index 018449f7c9a..33b4b4092f7 100644 --- a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr +++ b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr @@ -1,135 +1,773 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'config': dict({ - 'backflush_enabled': False, - 'bbw_settings': None, - 'boilers': dict({ - 'CoffeeBoiler1': dict({ - 'current_temperature': 96.5, - 'enabled': True, - 'target_temperature': 95, - }), - 'SteamBoiler': dict({ - 'current_temperature': 123.80000305175781, - 'enabled': True, - 'target_temperature': 123.9000015258789, - }), - }), - 'brew_active': False, - 'brew_active_duration': 0, - 'dose_hot_water': 8, - 'doses': dict({ - '1': 135, - '2': 97, - '3': 108, - '4': 121, - }), - 'plumbed_in': True, - 'prebrew_configuration': dict({ - '1': list([ - dict({ - 'off_time': 1, - 'on_time': 0.5, - }), - dict({ - 'off_time': 4, - 'on_time': 0, - }), - ]), - '2': list([ - dict({ - 'off_time': 1, - 'on_time': 0.5, - }), - dict({ - 'off_time': 4, - 'on_time': 0, - }), - ]), - '3': list([ - dict({ - 'off_time': 3.3, - 'on_time': 3.3, - }), - dict({ - 'off_time': 4, - 'on_time': 0, - }), - ]), - '4': list([ - dict({ - 'off_time': 2, - 'on_time': 2, - }), - dict({ - 'off_time': 4, - 'on_time': 0, - }), - ]), - }), - 'prebrew_mode': 'TypeB', - 'scale': None, - 'smart_standby': dict({ - 'enabled': True, - 'minutes': 10, - 'mode': 'LastBrewing', - }), - 'turned_on': True, - 'wake_up_sleep_entries': dict({ - 'Os2OswX': dict({ - 'days': list([ - 'monday', - 'tuesday', - 'wednesday', - 'thursday', - 'friday', - 'saturday', - 'sunday', - ]), - 'enabled': True, - 'entry_id': 'Os2OswX', - 'steam': True, - 'time_off': '24:0', - 'time_on': '22:0', - }), - 'aXFz5bJ': dict({ - 'days': list([ - 'sunday', - ]), - 'enabled': True, - 'entry_id': 'aXFz5bJ', - 'steam': True, - 'time_off': '7:30', - 'time_on': '7:0', - }), - }), - 'water_contact': True, + 'bluetooth_available': dict({ + 'mac': False, + 'options_enabled': True, + 'token': True, }), - 'firmware': list([ - dict({ - 'machine': dict({ - 'current_version': '1.40', - 'latest_version': '1.55', + 'device': dict({ + 'dashboard': dict({ + 'available_firmware_update': False, + 'ble_auth_token': None, + 'coffee_station': None, + 'config': dict({ + 'CMBackFlush': dict({ + 'last_cleaning_start_time': '2025-03-29T08:25:47.166000+00:00', + 'status': 'Off', + }), + 'CMCoffeeBoiler': dict({ + 'enabled': True, + 'enabled_supported': False, + 'ready_start_time': None, + 'status': 'Ready', + 'target_temperature': 95.0, + 'target_temperature_max': 110, + 'target_temperature_min': 80, + 'target_temperature_step': 0.1, + }), + 'CMGroupDoses': dict({ + 'available_modes': list([ + 'PulsesType', + ]), + 'brewing_pressure': None, + 'brewing_pressure_supported': False, + 'continuous_dose': None, + 'continuous_dose_supported': False, + 'doses': dict({ + 'pulses_type': list([ + dict({ + 'dose': 126.0, + 'dose_index': 'DoseA', + 'dose_max': 9999.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + dict({ + 'dose': 126.0, + 'dose_index': 'DoseB', + 'dose_max': 9999.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + dict({ + 'dose': 160.0, + 'dose_index': 'DoseC', + 'dose_max': 9999.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + dict({ + 'dose': 77.0, + 'dose_index': 'DoseD', + 'dose_max': 9999.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + ]), + }), + 'mirror_with_group_1': None, + 'mirror_with_group_1_not_effective': False, + 'mirror_with_group_1_supported': False, + 'mode': 'PulsesType', + 'profile': None, + }), + 'CMHotWaterDose': dict({ + 'doses': list([ + dict({ + 'dose': 8.0, + 'dose_index': 'DoseA', + 'dose_max': 90.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + ]), + 'enabled': True, + 'enabled_supported': False, + }), + 'CMMachineStatus': dict({ + 'available_modes': list([ + 'BrewingMode', + 'StandBy', + ]), + 'brewing_start_time': None, + 'mode': 'BrewingMode', + 'next_status': dict({ + 'start_time': '2025-03-24T22:59:55.332000+00:00', + 'status': 'StandBy', + }), + 'status': 'PoweredOn', + }), + 'CMPreBrewing': dict({ + 'available_modes': list([ + 'PreBrewing', + 'PreInfusion', + 'Disabled', + ]), + 'dose_index_supported': True, + 'mode': 'PreInfusion', + 'times': dict({ + 'pre_brewing': list([ + dict({ + 'dose_index': 'DoseA', + 'seconds': dict({ + 'In': 0.5, + 'Out': 1.0, + }), + 'seconds_max': dict({ + 'In': 10.0, + 'Out': 10.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseB', + 'seconds': dict({ + 'In': 0.5, + 'Out': 1.0, + }), + 'seconds_max': dict({ + 'In': 10.0, + 'Out': 10.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseC', + 'seconds': dict({ + 'In': 3.3, + 'Out': 3.3, + }), + 'seconds_max': dict({ + 'In': 10.0, + 'Out': 10.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseD', + 'seconds': dict({ + 'In': 2.0, + 'Out': 2.0, + }), + 'seconds_max': dict({ + 'In': 10.0, + 'Out': 10.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + ]), + 'pre_infusion': list([ + dict({ + 'dose_index': 'DoseA', + 'seconds': dict({ + 'In': 0.0, + 'Out': 4.0, + }), + 'seconds_max': dict({ + 'In': 25.0, + 'Out': 25.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseB', + 'seconds': dict({ + 'In': 0.0, + 'Out': 4.0, + }), + 'seconds_max': dict({ + 'In': 25.0, + 'Out': 25.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseC', + 'seconds': dict({ + 'In': 0.0, + 'Out': 4.0, + }), + 'seconds_max': dict({ + 'In': 25.0, + 'Out': 25.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseD', + 'seconds': dict({ + 'In': 0.0, + 'Out': 4.0, + }), + 'seconds_max': dict({ + 'In': 25.0, + 'Out': 25.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + ]), + }), + }), + 'CMSteamBoilerTemperature': dict({ + 'enabled': True, + 'enabled_supported': True, + 'ready_start_time': None, + 'status': 'Off', + 'target_temperature': 123.9, + 'target_temperature_max': 140, + 'target_temperature_min': 95, + 'target_temperature_step': 0.1, + 'target_temperature_supported': True, + }), }), + 'connected': True, + 'connection_date': '2025-03-20T16:44:47.479000+00:00', + 'image_url': 'https://lion.lamarzocco.io/img/thing-model/detail/gs3av/gs3av-1.png', + 'location': 'HOME', + 'model_code': 'GS3AV', + 'model_name': 'GS3 AV', + 'name': 'GS012345', + 'offline_mode': False, + 'require_firmware_update': False, + 'serial_number': '**REDACTED**', + 'type': 'CoffeeMachine', + 'widgets': list([ + dict({ + 'code': 'CMMachineStatus', + 'index': 1, + 'output': dict({ + 'available_modes': list([ + 'BrewingMode', + 'StandBy', + ]), + 'brewing_start_time': None, + 'mode': 'BrewingMode', + 'next_status': dict({ + 'start_time': '2025-03-24T22:59:55.332000+00:00', + 'status': 'StandBy', + }), + 'status': 'PoweredOn', + }), + }), + dict({ + 'code': 'CMCoffeeBoiler', + 'index': 1, + 'output': dict({ + 'enabled': True, + 'enabled_supported': False, + 'ready_start_time': None, + 'status': 'Ready', + 'target_temperature': 95.0, + 'target_temperature_max': 110, + 'target_temperature_min': 80, + 'target_temperature_step': 0.1, + }), + }), + dict({ + 'code': 'CMSteamBoilerTemperature', + 'index': 1, + 'output': dict({ + 'enabled': True, + 'enabled_supported': True, + 'ready_start_time': None, + 'status': 'Off', + 'target_temperature': 123.9, + 'target_temperature_max': 140, + 'target_temperature_min': 95, + 'target_temperature_step': 0.1, + 'target_temperature_supported': True, + }), + }), + dict({ + 'code': 'CMGroupDoses', + 'index': 1, + 'output': dict({ + 'available_modes': list([ + 'PulsesType', + ]), + 'brewing_pressure': None, + 'brewing_pressure_supported': False, + 'continuous_dose': None, + 'continuous_dose_supported': False, + 'doses': dict({ + 'pulses_type': list([ + dict({ + 'dose': 126.0, + 'dose_index': 'DoseA', + 'dose_max': 9999.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + dict({ + 'dose': 126.0, + 'dose_index': 'DoseB', + 'dose_max': 9999.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + dict({ + 'dose': 160.0, + 'dose_index': 'DoseC', + 'dose_max': 9999.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + dict({ + 'dose': 77.0, + 'dose_index': 'DoseD', + 'dose_max': 9999.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + ]), + }), + 'mirror_with_group_1': None, + 'mirror_with_group_1_not_effective': False, + 'mirror_with_group_1_supported': False, + 'mode': 'PulsesType', + 'profile': None, + }), + }), + dict({ + 'code': 'CMPreBrewing', + 'index': 1, + 'output': dict({ + 'available_modes': list([ + 'PreBrewing', + 'PreInfusion', + 'Disabled', + ]), + 'dose_index_supported': True, + 'mode': 'PreInfusion', + 'times': dict({ + 'pre_brewing': list([ + dict({ + 'dose_index': 'DoseA', + 'seconds': dict({ + 'In': 0.5, + 'Out': 1.0, + }), + 'seconds_max': dict({ + 'In': 10.0, + 'Out': 10.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseB', + 'seconds': dict({ + 'In': 0.5, + 'Out': 1.0, + }), + 'seconds_max': dict({ + 'In': 10.0, + 'Out': 10.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseC', + 'seconds': dict({ + 'In': 3.3, + 'Out': 3.3, + }), + 'seconds_max': dict({ + 'In': 10.0, + 'Out': 10.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseD', + 'seconds': dict({ + 'In': 2.0, + 'Out': 2.0, + }), + 'seconds_max': dict({ + 'In': 10.0, + 'Out': 10.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + ]), + 'pre_infusion': list([ + dict({ + 'dose_index': 'DoseA', + 'seconds': dict({ + 'In': 0.0, + 'Out': 4.0, + }), + 'seconds_max': dict({ + 'In': 25.0, + 'Out': 25.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseB', + 'seconds': dict({ + 'In': 0.0, + 'Out': 4.0, + }), + 'seconds_max': dict({ + 'In': 25.0, + 'Out': 25.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseC', + 'seconds': dict({ + 'In': 0.0, + 'Out': 4.0, + }), + 'seconds_max': dict({ + 'In': 25.0, + 'Out': 25.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseD', + 'seconds': dict({ + 'In': 0.0, + 'Out': 4.0, + }), + 'seconds_max': dict({ + 'In': 25.0, + 'Out': 25.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + ]), + }), + }), + }), + dict({ + 'code': 'CMHotWaterDose', + 'index': 1, + 'output': dict({ + 'doses': list([ + dict({ + 'dose': 8.0, + 'dose_index': 'DoseA', + 'dose_max': 90.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + ]), + 'enabled': True, + 'enabled_supported': False, + }), + }), + dict({ + 'code': 'CMBackFlush', + 'index': 1, + 'output': dict({ + 'last_cleaning_start_time': '2025-03-29T08:25:47.166000+00:00', + 'status': 'Off', + }), + }), + ]), }), - dict({ - 'gateway': dict({ - 'current_version': 'v3.1-rc4', - 'latest_version': 'v3.5-rc3', + 'schedule': dict({ + 'available_firmware_update': False, + 'ble_auth_token': None, + 'coffee_station': None, + 'connected': True, + 'connection_date': '2025-03-21T03:00:19.892000+00:00', + 'image_url': 'https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png', + 'location': None, + 'model_code': 'LINEAMICRA', + 'model_name': 'Linea Micra', + 'name': 'MR123456', + 'offline_mode': False, + 'require_firmware_update': False, + 'serial_number': '**REDACTED**', + 'smart_wake_up_sleep': dict({ + 'schedules': list([ + dict({ + 'days': list([ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]), + 'enabled': True, + 'id': 'Os2OswX', + 'offTimeMinutes': 1440, + 'onTimeMinutes': 1320, + 'steamBoiler': True, + }), + dict({ + 'days': list([ + 'Sunday', + ]), + 'enabled': True, + 'id': 'aXFz5bJ', + 'offTimeMinutes': 450, + 'onTimeMinutes': 420, + 'steamBoiler': False, + }), + ]), + 'schedules_dict': dict({ + 'Os2OswX': dict({ + 'days': list([ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]), + 'enabled': True, + 'id': 'Os2OswX', + 'offTimeMinutes': 1440, + 'onTimeMinutes': 1320, + 'steamBoiler': True, + }), + 'aXFz5bJ': dict({ + 'days': list([ + 'Sunday', + ]), + 'enabled': True, + 'id': 'aXFz5bJ', + 'offTimeMinutes': 450, + 'onTimeMinutes': 420, + 'steamBoiler': False, + }), + }), + 'smart_stand_by_after': 'PowerOn', + 'smart_stand_by_enabled': True, + 'smart_stand_by_minutes': 10, + 'smart_stand_by_minutes_max': 30, + 'smart_stand_by_minutes_min': 1, + 'smart_stand_by_minutes_step': 1, }), + 'smart_wake_up_sleep_supported': True, + 'type': 'CoffeeMachine', }), - ]), - 'model': 'GS3 AV', - 'statistics': dict({ - 'continous': 2252, - 'drink_stats': dict({ - '1': 1047, - '2': 560, - '3': 468, - '4': 312, + 'serial_number': '**REDACTED**', + 'settings': dict({ + 'actual_firmwares': list([ + dict({ + 'available_update': dict({ + 'build_version': 'v5.0.10', + 'change_log': ''' + What’s new in this version: + + * fixed an issue that could cause the machine powers up outside scheduled time + * minor improvements + ''', + 'thing_model_code': 'LineaMicra', + 'type': 'Gateway', + }), + 'build_version': 'v5.0.9', + 'change_log': ''' + What’s new in this version: + + * New La Marzocco compatibility + * Improved connectivity + * Improved pairing process + * Improved statistics + * Boilers heating time + * Last backflush date (GS3 MP excluded) + * Automatic gateway updates option + ''', + 'status': 'ToUpdate', + 'thing_model_code': 'LineaMicra', + 'type': 'Gateway', + }), + dict({ + 'available_update': None, + 'build_version': 'v1.17', + 'change_log': 'None', + 'status': 'Updated', + 'thing_model_code': 'LineaMicra', + 'type': 'Machine', + }), + ]), + 'auto_update': False, + 'auto_update_supported': True, + 'available_firmware_update': False, + 'ble_auth_token': None, + 'coffee_station': None, + 'connected': True, + 'connection_date': '2025-03-21T03:00:19.892000+00:00', + 'cropster_active': False, + 'cropster_supported': False, + 'factory_reset_supported': True, + 'firmwares': dict({ + 'Gateway': dict({ + 'available_update': dict({ + 'build_version': 'v5.0.10', + 'change_log': ''' + What’s new in this version: + + * fixed an issue that could cause the machine powers up outside scheduled time + * minor improvements + ''', + 'thing_model_code': 'LineaMicra', + 'type': 'Gateway', + }), + 'build_version': 'v5.0.9', + 'change_log': ''' + What’s new in this version: + + * New La Marzocco compatibility + * Improved connectivity + * Improved pairing process + * Improved statistics + * Boilers heating time + * Last backflush date (GS3 MP excluded) + * Automatic gateway updates option + ''', + 'status': 'ToUpdate', + 'thing_model_code': 'LineaMicra', + 'type': 'Gateway', + }), + 'Machine': dict({ + 'available_update': None, + 'build_version': 'v1.17', + 'change_log': 'None', + 'status': 'Updated', + 'thing_model_code': 'LineaMicra', + 'type': 'Machine', + }), + }), + 'hemro_active': False, + 'hemro_supported': False, + 'image_url': 'https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png', + 'is_plumbed_in': True, + 'location': None, + 'model_code': 'LINEAMICRA', + 'model_name': 'Linea Micra', + 'name': 'MR123456', + 'offline_mode': False, + 'plumb_in_supported': True, + 'require_firmware_update': False, + 'serial_number': '**REDACTED**', + 'type': 'CoffeeMachine', + 'wifi_rssi': -51, + 'wifi_ssid': 'MyWifi', }), - 'total_flushes': 1740, }), }) # --- diff --git a/tests/components/lamarzocco/snapshots/test_init.ambr b/tests/components/lamarzocco/snapshots/test_init.ambr index 4c210136bd2..18b2fd0fbc3 100644 --- a/tests/components/lamarzocco/snapshots/test_init.ambr +++ b/tests/components/lamarzocco/snapshots/test_init.ambr @@ -29,47 +29,14 @@ 'labels': set({ }), 'manufacturer': 'La Marzocco', - 'model': , - 'model_id': , + 'model': 'GS3 AV', + 'model_id': 'GS3AV', 'name': 'GS012345', 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GS012345', 'suggested_area': None, - 'sw_version': '1.40', + 'sw_version': 'v1.17', 'via_device_id': None, }) # --- -# name: test_scale_device[Linea Mini] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'lamarzocco', - '44:b7:d0:74:5f:90', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Acaia', - 'model': 'Lunar', - 'model_id': 'Y.301', - 'name': 'LMZ-123A45', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': , - }) -# --- diff --git a/tests/components/lamarzocco/snapshots/test_number.ambr b/tests/components/lamarzocco/snapshots/test_number.ambr index de1f11b14eb..8f59ce4a6fa 100644 --- a/tests/components/lamarzocco/snapshots/test_number.ambr +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_general_numbers[coffee_target_temperature-94-set_temp-kwargs0] +# name: test_general_numbers[coffee_target_temperature-94-set_coffee_target_temperature-kwargs0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -15,10 +15,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '95', + 'state': '95.0', }) # --- -# name: test_general_numbers[coffee_target_temperature-94-set_temp-kwargs0].1 +# name: test_general_numbers[coffee_target_temperature-94-set_coffee_target_temperature-kwargs0].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -63,9 +63,9 @@ 'device_class': 'duration', 'friendly_name': 'GS012345 Smart standby time', 'max': 240, - 'min': 10, + 'min': 0, 'mode': , - 'step': 10, + 'step': 1, 'unit_of_measurement': , }), 'context': , @@ -83,9 +83,9 @@ 'area_id': None, 'capabilities': dict({ 'max': 240, - 'min': 10, + 'min': 0, 'mode': , - 'step': 10, + 'step': 1, }), 'config_entry_id': , 'config_subentry_id': , @@ -115,623 +115,33 @@ 'unit_of_measurement': , }) # --- -# name: test_gs3_exclusive[steam_target_temperature-131-set_temp-kwargs0-GS3 AV] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'GS012345 Steam target temperature', - 'max': 131, - 'min': 126, - 'mode': , - 'step': 1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_steam_target_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '123.900001525879', - }) -# --- -# name: test_gs3_exclusive[steam_target_temperature-131-set_temp-kwargs0-GS3 AV].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 131, - 'min': 126, - 'mode': , - 'step': 1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': None, - 'entity_id': 'number.gs012345_steam_target_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Steam target temperature', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'steam_temp', - 'unique_id': 'GS012345_steam_temp', - 'unit_of_measurement': , - }) -# --- -# name: test_gs3_exclusive[steam_target_temperature-131-set_temp-kwargs0-GS3 MP] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'GS012345 Steam target temperature', - 'max': 131, - 'min': 126, - 'mode': , - 'step': 1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_steam_target_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '123.900001525879', - }) -# --- -# name: test_gs3_exclusive[steam_target_temperature-131-set_temp-kwargs0-GS3 MP].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 131, - 'min': 126, - 'mode': , - 'step': 1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': None, - 'entity_id': 'number.gs012345_steam_target_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Steam target temperature', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'steam_temp', - 'unique_id': 'GS012345_steam_temp', - 'unit_of_measurement': , - }) -# --- -# name: test_gs3_exclusive[tea_water_duration-15-set_dose_tea_water-kwargs1-GS3 AV] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Tea water duration', - 'max': 30, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_tea_water_duration', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '8', - }) -# --- -# name: test_gs3_exclusive[tea_water_duration-15-set_dose_tea_water-kwargs1-GS3 AV].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 30, - 'min': 0, - 'mode': , - 'step': 1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': None, - 'entity_id': 'number.gs012345_tea_water_duration', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Tea water duration', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'tea_water_duration', - 'unique_id': 'GS012345_tea_water_duration', - 'unit_of_measurement': , - }) -# --- -# name: test_gs3_exclusive[tea_water_duration-15-set_dose_tea_water-kwargs1-GS3 MP] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Tea water duration', - 'max': 30, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_tea_water_duration', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '8', - }) -# --- -# name: test_gs3_exclusive[tea_water_duration-15-set_dose_tea_water-kwargs1-GS3 MP].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 30, - 'min': 0, - 'mode': , - 'step': 1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': None, - 'entity_id': 'number.gs012345_tea_water_duration', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Tea water duration', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'tea_water_duration', - 'unique_id': 'GS012345_tea_water_duration', - 'unit_of_measurement': , - }) -# --- -# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS012345_dose_key_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Dose Key 1', - 'max': 999, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': 'ticks', - }), - 'context': , - 'entity_id': 'number.gs012345_dose_key_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '135', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS012345_dose_key_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Dose Key 2', - 'max': 999, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': 'ticks', - }), - 'context': , - 'entity_id': 'number.gs012345_dose_key_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '97', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS012345_dose_key_3-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Dose Key 3', - 'max': 999, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': 'ticks', - }), - 'context': , - 'entity_id': 'number.gs012345_dose_key_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '108', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS012345_dose_key_4-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Dose Key 4', - 'max': 999, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': 'ticks', - }), - 'context': , - 'entity_id': 'number.gs012345_dose_key_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '121', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-TypeA-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Prebrew off time Key 1', - 'max': 10, - 'min': 1, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_prebrew_off_time_key_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-TypeA-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Prebrew off time Key 2', - 'max': 10, - 'min': 1, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_prebrew_off_time_key_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-TypeA-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_3-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Prebrew off time Key 3', - 'max': 10, - 'min': 1, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_prebrew_off_time_key_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.3', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-TypeA-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_4-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Prebrew off time Key 4', - 'max': 10, - 'min': 1, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_prebrew_off_time_key_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-TypeA-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Prebrew on time Key 1', - 'max': 10, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_prebrew_on_time_key_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-TypeA-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Prebrew on time Key 2', - 'max': 10, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_prebrew_on_time_key_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-TypeA-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_3-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Prebrew on time Key 3', - 'max': 10, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_prebrew_on_time_key_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.3', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-TypeA-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_4-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Prebrew on time Key 4', - 'max': 10, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_prebrew_on_time_key_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Preinfusion time Key 1', - 'max': 29, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_preinfusion_time_key_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Preinfusion time Key 2', - 'max': 29, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_preinfusion_time_key_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_3-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Preinfusion time Key 3', - 'max': 29, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_preinfusion_time_key_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_4-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Preinfusion time Key 4', - 'max': 29, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_preinfusion_time_key_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4', - }) -# --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-TypeA-6-kwargs0-Linea Mini] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'LM012345 Prebrew off time', - 'max': 10, - 'min': 1, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.lm012345_prebrew_off_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3', - }) -# --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-TypeA-6-kwargs0-Linea Mini].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 10, - 'min': 1, - 'mode': , - 'step': 0.1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.lm012345_prebrew_off_time', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Prebrew off time', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'prebrew_off', - 'unique_id': 'LM012345_prebrew_off', - 'unit_of_measurement': , - }) -# --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-TypeA-6-kwargs0-Micra] +# name: test_prebrew_off[Linea Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'MR012345 Prebrew off time', 'max': 10, - 'min': 1, + 'min': 0, 'mode': , 'step': 0.1, - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'number.mr012345_prebrew_off_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '5.0', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-TypeA-6-kwargs0-Micra].1 +# name: test_prebrew_off[Linea Micra].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ 'max': 10, - 'min': 1, + 'min': 0, 'mode': , 'step': 0.1, }), @@ -758,96 +168,38 @@ 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'prebrew_off', + 'translation_key': 'prebrew_time_off', 'unique_id': 'MR012345_prebrew_off', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-TypeA-6-kwargs1-Linea Mini] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'LM012345 Prebrew on time', - 'max': 10, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.lm012345_prebrew_on_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3', - }) -# --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-TypeA-6-kwargs1-Linea Mini].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 10, - 'min': 2, - 'mode': , - 'step': 0.1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.lm012345_prebrew_on_time', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Prebrew on time', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'prebrew_on', - 'unique_id': 'LM012345_prebrew_on', - 'unit_of_measurement': , - }) -# --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-TypeA-6-kwargs1-Micra] +# name: test_prebrew_on[Linea Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'MR012345 Prebrew on time', 'max': 10, - 'min': 2, + 'min': 0, 'mode': , 'step': 0.1, - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'number.mr012345_prebrew_on_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '5.0', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-TypeA-6-kwargs1-Micra].1 +# name: test_prebrew_on[Linea Micra].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ 'max': 10, - 'min': 2, + 'min': 0, 'mode': , 'step': 0.1, }), @@ -874,76 +226,18 @@ 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'prebrew_on', + 'translation_key': 'prebrew_time_on', 'unique_id': 'MR012345_prebrew_on', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[preinfusion_time-set_preinfusion_time-TypeB-7-kwargs2-Linea Mini] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'LM012345 Preinfusion time', - 'max': 29, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.lm012345_preinfusion_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3', - }) -# --- -# name: test_pre_brew_infusion_numbers[preinfusion_time-set_preinfusion_time-TypeB-7-kwargs2-Linea Mini].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 29, - 'min': 2, - 'mode': , - 'step': 0.1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.lm012345_preinfusion_time', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Preinfusion time', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'preinfusion_off', - 'unique_id': 'LM012345_preinfusion_off', - 'unit_of_measurement': , - }) -# --- -# name: test_pre_brew_infusion_numbers[preinfusion_time-set_preinfusion_time-TypeB-7-kwargs2-Micra] +# name: test_preinfusion[Linea Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'MR012345 Preinfusion time', - 'max': 29, - 'min': 2, + 'max': 10, + 'min': 0, 'mode': , 'step': 0.1, 'unit_of_measurement': , @@ -953,17 +247,17 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '4', + 'state': '4.0', }) # --- -# name: test_pre_brew_infusion_numbers[preinfusion_time-set_preinfusion_time-TypeB-7-kwargs2-Micra].1 +# name: test_preinfusion[Linea Micra].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'max': 29, - 'min': 2, + 'max': 10, + 'min': 0, 'mode': , 'step': 0.1, }), @@ -990,120 +284,8 @@ 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'preinfusion_off', + 'translation_key': 'preinfusion_time', 'unique_id': 'MR012345_preinfusion_off', 'unit_of_measurement': , }) # --- -# name: test_set_target[Linea Mini-1] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'LMZ-123A45 Brew by weight target 1', - 'max': 100, - 'min': 1, - 'mode': , - 'step': 1, - }), - 'context': , - 'entity_id': 'number.lmz_123a45_brew_by_weight_target_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '32', - }) -# --- -# name: test_set_target[Linea Mini-1].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 100, - 'min': 1, - 'mode': , - 'step': 1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.lmz_123a45_brew_by_weight_target_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': 'Brew by weight target 1', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'scale_target_key', - 'unique_id': 'LM012345_scale_target_key1', - 'unit_of_measurement': None, - }) -# --- -# name: test_set_target[Linea Mini-2] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'LMZ-123A45 Brew by weight target 2', - 'max': 100, - 'min': 1, - 'mode': , - 'step': 1, - }), - 'context': , - 'entity_id': 'number.lmz_123a45_brew_by_weight_target_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '45', - }) -# --- -# name: test_set_target[Linea Mini-2].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 100, - 'min': 1, - 'mode': , - 'step': 1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.lmz_123a45_brew_by_weight_target_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': 'Brew by weight target 2', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'scale_target_key', - 'unique_id': 'LM012345_scale_target_key2', - 'unit_of_measurement': None, - }) -# --- diff --git a/tests/components/lamarzocco/snapshots/test_select.ambr b/tests/components/lamarzocco/snapshots/test_select.ambr index 2e88688652a..218b0092a49 100644 --- a/tests/components/lamarzocco/snapshots/test_select.ambr +++ b/tests/components/lamarzocco/snapshots/test_select.ambr @@ -1,60 +1,4 @@ # serializer version: 1 -# name: test_active_bbw_recipe[Linea Mini] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'LMZ-123A45 Active brew by weight recipe', - 'options': list([ - 'a', - 'b', - ]), - }), - 'context': , - 'entity_id': 'select.lmz_123a45_active_brew_by_weight_recipe', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'a', - }) -# --- -# name: test_active_bbw_recipe[Linea Mini].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'a', - 'b', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.lmz_123a45_active_brew_by_weight_recipe', - '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': 'Active brew by weight recipe', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'active_bbw', - 'unique_id': 'LM012345_active_bbw', - 'unit_of_measurement': None, - }) -# --- # name: test_pre_brew_infusion_select[GS3 AV] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -113,6 +57,64 @@ 'unit_of_measurement': None, }) # --- +# name: test_pre_brew_infusion_select[Linea Micra] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MR012345 Prebrew/-infusion mode', + 'options': list([ + 'disabled', + 'prebrew', + 'preinfusion', + ]), + }), + 'context': , + 'entity_id': 'select.mr012345_prebrew_infusion_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'preinfusion', + }) +# --- +# name: test_pre_brew_infusion_select[Linea Micra].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'disabled', + 'prebrew', + 'preinfusion', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.mr012345_prebrew_infusion_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': 'Prebrew/-infusion mode', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'prebrew_infusion_select', + 'unique_id': 'MR012345_prebrew_infusion_select', + 'unit_of_measurement': None, + }) +# --- # name: test_pre_brew_infusion_select[Linea Mini] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -128,7 +130,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'preinfusion', + 'state': 'disabled', }) # --- # name: test_pre_brew_infusion_select[Linea Mini].1 @@ -171,64 +173,6 @@ 'unit_of_measurement': None, }) # --- -# name: test_pre_brew_infusion_select[Micra] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'MR012345 Prebrew/-infusion mode', - 'options': list([ - 'disabled', - 'prebrew', - 'preinfusion', - ]), - }), - 'context': , - 'entity_id': 'select.mr012345_prebrew_infusion_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'preinfusion', - }) -# --- -# name: test_pre_brew_infusion_select[Micra].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'disabled', - 'prebrew', - 'preinfusion', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.mr012345_prebrew_infusion_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': 'Prebrew/-infusion mode', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'prebrew_infusion_select', - 'unique_id': 'MR012345_prebrew_infusion_select', - 'unit_of_measurement': None, - }) -# --- # name: test_smart_standby_mode StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -243,7 +187,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'last_brewing', + 'state': 'power_on', }) # --- # name: test_smart_standby_mode.1 @@ -285,7 +229,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_steam_boiler_level[Micra] +# name: test_steam_boiler_level[Linea Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'MR012345 Steam level', @@ -300,10 +244,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '3', }) # --- -# name: test_steam_boiler_level[Micra].1 +# name: test_steam_boiler_level[Linea Micra].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index 996dff93433..46abb93dd2e 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -1,64 +1,10 @@ # serializer version: 1 -# name: test_scale_battery[Linea Mini] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'LMZ-123A45 Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.lmz_123a45_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '64', - }) -# --- -# name: test_scale_battery[Linea Mini].1 +# name: test_sensors[sensor.gs012345_coffee_boiler_ready_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.lmz_123a45_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': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'LM012345_scale_battery', - 'unit_of_measurement': '%', - }) -# --- -# name: test_sensors[sensor.gs012345_coffees_made_key_1-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -66,7 +12,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.gs012345_coffees_made_key_1', + 'entity_id': 'sensor.gs012345_coffee_boiler_ready_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -76,40 +22,37 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Coffees made Key 1', + 'original_name': 'Coffee boiler ready time', 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'drink_stats_coffee_key', - 'unique_id': 'GS012345_drink_stats_coffee_key_key1', - 'unit_of_measurement': 'coffees', + 'translation_key': 'coffee_boiler_ready_time', + 'unique_id': 'GS012345_coffee_boiler_ready_time', + 'unit_of_measurement': None, }) # --- -# name: test_sensors[sensor.gs012345_coffees_made_key_1-state] +# name: test_sensors[sensor.gs012345_coffee_boiler_ready_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Coffees made Key 1', - 'state_class': , - 'unit_of_measurement': 'coffees', + 'device_class': 'timestamp', + 'friendly_name': 'GS012345 Coffee boiler ready time', }), 'context': , - 'entity_id': 'sensor.gs012345_coffees_made_key_1', + 'entity_id': 'sensor.gs012345_coffee_boiler_ready_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1047', + 'state': 'unknown', }) # --- -# name: test_sensors[sensor.gs012345_coffees_made_key_2-entry] +# name: test_sensors[sensor.gs012345_last_cleaning_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -117,7 +60,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.gs012345_coffees_made_key_2', + 'entity_id': 'sensor.gs012345_last_cleaning_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -127,40 +70,37 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Coffees made Key 2', + 'original_name': 'Last cleaning time', 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'drink_stats_coffee_key', - 'unique_id': 'GS012345_drink_stats_coffee_key_key2', - 'unit_of_measurement': 'coffees', + 'translation_key': 'last_cleaning_time', + 'unique_id': 'GS012345_last_cleaning_time', + 'unit_of_measurement': None, }) # --- -# name: test_sensors[sensor.gs012345_coffees_made_key_2-state] +# name: test_sensors[sensor.gs012345_last_cleaning_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Coffees made Key 2', - 'state_class': , - 'unit_of_measurement': 'coffees', + 'device_class': 'timestamp', + 'friendly_name': 'GS012345 Last cleaning time', }), 'context': , - 'entity_id': 'sensor.gs012345_coffees_made_key_2', + 'entity_id': 'sensor.gs012345_last_cleaning_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '560', + 'state': '2025-03-29T08:25:47+00:00', }) # --- -# name: test_sensors[sensor.gs012345_coffees_made_key_3-entry] +# name: test_sensors[sensor.gs012345_steam_boiler_ready_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -168,7 +108,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.gs012345_coffees_made_key_3', + 'entity_id': 'sensor.gs012345_steam_boiler_ready_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -178,243 +118,29 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Coffees made Key 3', + 'original_name': 'Steam boiler ready time', 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'drink_stats_coffee_key', - 'unique_id': 'GS012345_drink_stats_coffee_key_key3', - 'unit_of_measurement': 'coffees', + 'translation_key': 'steam_boiler_ready_time', + 'unique_id': 'GS012345_steam_boiler_ready_time', + 'unit_of_measurement': None, }) # --- -# name: test_sensors[sensor.gs012345_coffees_made_key_3-state] +# name: test_sensors[sensor.gs012345_steam_boiler_ready_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Coffees made Key 3', - 'state_class': , - 'unit_of_measurement': 'coffees', + 'device_class': 'timestamp', + 'friendly_name': 'GS012345 Steam boiler ready time', }), 'context': , - 'entity_id': 'sensor.gs012345_coffees_made_key_3', + 'entity_id': 'sensor.gs012345_steam_boiler_ready_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '468', - }) -# --- -# name: test_sensors[sensor.gs012345_coffees_made_key_4-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.gs012345_coffees_made_key_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': 'Coffees made Key 4', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'drink_stats_coffee_key', - 'unique_id': 'GS012345_drink_stats_coffee_key_key4', - 'unit_of_measurement': 'coffees', - }) -# --- -# name: test_sensors[sensor.gs012345_coffees_made_key_4-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Coffees made Key 4', - 'state_class': , - 'unit_of_measurement': 'coffees', - }), - 'context': , - 'entity_id': 'sensor.gs012345_coffees_made_key_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '312', - }) -# --- -# name: test_sensors[sensor.gs012345_current_coffee_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.gs012345_current_coffee_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Current coffee temperature', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'current_temp_coffee', - 'unique_id': 'GS012345_current_temp_coffee', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.gs012345_current_coffee_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'GS012345 Current coffee temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.gs012345_current_coffee_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '96.5', - }) -# --- -# name: test_sensors[sensor.gs012345_current_steam_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.gs012345_current_steam_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Current steam temperature', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'current_temp_steam', - 'unique_id': 'GS012345_current_temp_steam', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.gs012345_current_steam_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'GS012345 Current steam temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.gs012345_current_steam_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '123.800003051758', - }) -# --- -# name: test_sensors[sensor.gs012345_shot_timer-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.gs012345_shot_timer', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Shot timer', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'shot_timer', - 'unique_id': 'GS012345_shot_timer', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.gs012345_shot_timer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Shot timer', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.gs012345_shot_timer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.gs012345_total_coffees_made-entry] @@ -448,7 +174,7 @@ 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'drink_stats_coffee', + 'translation_key': 'total_coffees_made', 'unique_id': 'GS012345_drink_stats_coffee', 'unit_of_measurement': 'coffees', }) @@ -465,10 +191,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2387', + 'state': '1620', }) # --- -# name: test_sensors[sensor.gs012345_total_flushes_made-entry] +# name: test_sensors[sensor.gs012345_total_flushes_done-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -483,7 +209,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.gs012345_total_flushes_made', + 'entity_id': 'sensor.gs012345_total_flushes_done', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -495,27 +221,27 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Total flushes made', + 'original_name': 'Total flushes done', 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'drink_stats_flushing', + 'translation_key': 'total_flushes_done', 'unique_id': 'GS012345_drink_stats_flushing', 'unit_of_measurement': 'flushes', }) # --- -# name: test_sensors[sensor.gs012345_total_flushes_made-state] +# name: test_sensors[sensor.gs012345_total_flushes_done-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Total flushes made', + 'friendly_name': 'GS012345 Total flushes done', 'state_class': , 'unit_of_measurement': 'flushes', }), 'context': , - 'entity_id': 'sensor.gs012345_total_flushes_made', + 'entity_id': 'sensor.gs012345_total_flushes_done', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1740', + 'state': '1366', }) # --- diff --git a/tests/components/lamarzocco/snapshots/test_update.ambr b/tests/components/lamarzocco/snapshots/test_update.ambr index 17d0528c3d8..508d0d36911 100644 --- a/tests/components/lamarzocco/snapshots/test_update.ambr +++ b/tests/components/lamarzocco/snapshots/test_update.ambr @@ -27,7 +27,7 @@ 'original_name': 'Gateway firmware', 'platform': 'lamarzocco', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'gateway_firmware', 'unique_id': 'GS012345_gateway_firmware', 'unit_of_measurement': None, @@ -42,12 +42,12 @@ 'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png', 'friendly_name': 'GS012345 Gateway firmware', 'in_progress': False, - 'installed_version': 'v3.1-rc4', - 'latest_version': 'v3.5-rc3', + 'installed_version': 'v5.0.9', + 'latest_version': 'v5.0.10', 'release_summary': None, 'release_url': 'https://support-iot.lamarzocco.com/firmware-updates/', 'skipped_version': None, - 'supported_features': , + 'supported_features': , 'title': None, 'update_percentage': None, }), @@ -87,7 +87,7 @@ 'original_name': 'Machine firmware', 'platform': 'lamarzocco', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'machine_firmware', 'unique_id': 'GS012345_machine_firmware', 'unit_of_measurement': None, @@ -102,12 +102,12 @@ 'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png', 'friendly_name': 'GS012345 Machine firmware', 'in_progress': False, - 'installed_version': '1.40', - 'latest_version': '1.55', + 'installed_version': 'v1.17', + 'latest_version': 'v1.17', 'release_summary': None, 'release_url': 'https://support-iot.lamarzocco.com/firmware-updates/', 'skipped_version': None, - 'supported_features': , + 'supported_features': , 'title': None, 'update_percentage': None, }), @@ -116,6 +116,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- diff --git a/tests/components/lamarzocco/test_binary_sensor.py b/tests/components/lamarzocco/test_binary_sensor.py index d50d0ad9f84..570b5aef8ec 100644 --- a/tests/components/lamarzocco/test_binary_sensor.py +++ b/tests/components/lamarzocco/test_binary_sensor.py @@ -1,12 +1,11 @@ """Tests for La Marzocco binary sensors.""" +from collections.abc import Generator from datetime import timedelta from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory -from pylamarzocco.const import MachineModel from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.models import LaMarzoccoScale import pytest from syrupy import SnapshotAssertion @@ -19,9 +18,9 @@ from . import async_init_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensors( hass: HomeAssistant, - mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, @@ -35,16 +34,14 @@ async def test_binary_sensors( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_brew_active_does_not_exists( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry_no_local_connection: MockConfigEntry, -) -> None: - """Test the La Marzocco currently_making_coffee doesn't exist if host not set.""" - - await async_init_integration(hass, mock_config_entry_no_local_connection) - state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_brewing_active") - assert state is None +@pytest.fixture(autouse=True) +def mock_websocket_terminated() -> Generator[bool]: + """Mock websocket terminated.""" + with patch( + "homeassistant.components.lamarzocco.coordinator.LaMarzoccoUpdateCoordinator.websocket_terminated", + new=False, + ) as mock_websocket_terminated: + yield mock_websocket_terminated async def test_brew_active_unavailable( @@ -52,9 +49,9 @@ async def test_brew_active_unavailable( mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test the La Marzocco currently_making_coffee becomes unavailable.""" + """Test the La Marzocco brew active becomes unavailable.""" - mock_lamarzocco.websocket_connected = False + mock_lamarzocco.websocket.connected = False await async_init_integration(hass, mock_config_entry) state = hass.states.get( f"binary_sensor.{mock_lamarzocco.serial_number}_brewing_active" @@ -79,7 +76,8 @@ async def test_sensor_going_unavailable( assert state assert state.state != STATE_UNAVAILABLE - mock_lamarzocco.get_config.side_effect = RequestNotSuccessful("") + mock_lamarzocco.websocket.connected = False + mock_lamarzocco.get_dashboard.side_effect = RequestNotSuccessful("") freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -87,68 +85,3 @@ async def test_sensor_going_unavailable( state = hass.states.get(brewing_active_sensor) assert state assert state.state == STATE_UNAVAILABLE - - -@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) -async def test_scale_connectivity( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test the scale binary sensors.""" - await async_init_integration(hass, mock_config_entry) - - state = hass.states.get("binary_sensor.lmz_123a45_connectivity") - assert state - assert state == snapshot - - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry.device_id - assert entry == snapshot - - -@pytest.mark.parametrize( - "device_fixture", - [MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA], -) -async def test_other_models_no_scale_connectivity( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, -) -> None: - """Ensure the other models don't have a connectivity sensor.""" - await async_init_integration(hass, mock_config_entry) - - state = hass.states.get("binary_sensor.lmz_123a45_connectivity") - assert state is None - - -@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) -async def test_connectivity_on_new_scale_added( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, -) -> None: - """Ensure the connectivity binary sensor for a new scale is added automatically.""" - - mock_lamarzocco.config.scale = None - await async_init_integration(hass, mock_config_entry) - - state = hass.states.get("binary_sensor.scale_123a45_connectivity") - assert state is None - - mock_lamarzocco.config.scale = LaMarzoccoScale( - connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50 - ) - - freezer.tick(timedelta(minutes=10)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.scale_123a45_connectivity") - assert state diff --git a/tests/components/lamarzocco/test_calendar.py b/tests/components/lamarzocco/test_calendar.py index dd590a20db1..0d8db9bec89 100644 --- a/tests/components/lamarzocco/test_calendar.py +++ b/tests/components/lamarzocco/test_calendar.py @@ -127,7 +127,12 @@ async def test_no_calendar_events_global_disable( wake_up_sleep_entry_id = WAKE_UP_SLEEP_ENTRY_IDS[0] - mock_lamarzocco.config.wake_up_sleep_entries[wake_up_sleep_entry_id].enabled = False + wake_up_sleep_entry = mock_lamarzocco.schedule.smart_wake_up_sleep.schedules_dict[ + wake_up_sleep_entry_id + ] + + assert wake_up_sleep_entry + wake_up_sleep_entry.enabled = False test_time = datetime(2024, 1, 12, 11, tzinfo=dt_util.get_default_time_zone()) freezer.move_to(test_time) diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 02ade8f2b9c..38cdc10d8ab 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -1,11 +1,11 @@ """Test the La Marzocco config flow.""" from collections.abc import Generator +from copy import deepcopy from unittest.mock import AsyncMock, MagicMock, patch -from pylamarzocco.const import MachineModel +from pylamarzocco.const import ModelName from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful -from pylamarzocco.models import LaMarzoccoDeviceInfo import pytest from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE @@ -15,18 +15,12 @@ from homeassistant.config_entries import ( SOURCE_DHCP, SOURCE_USER, ConfigEntryState, + ConfigFlowResult, ) -from homeassistant.const import ( - CONF_ADDRESS, - CONF_HOST, - CONF_MAC, - CONF_MODEL, - CONF_NAME, - CONF_PASSWORD, - CONF_TOKEN, -) +from homeassistant.const import CONF_ADDRESS, CONF_MAC, CONF_PASSWORD, CONF_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import USER_INPUT, async_init_integration, get_bluetooth_service_info @@ -34,9 +28,18 @@ from . import USER_INPUT, async_init_integration, get_bluetooth_service_info from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.lamarzocco.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + async def __do_successful_user_step( - hass: HomeAssistant, result: FlowResult, mock_cloud_client: MagicMock -) -> FlowResult: + hass: HomeAssistant, result: ConfigFlowResult, mock_cloud_client: MagicMock +) -> ConfigFlowResult: """Successfully configure the user step.""" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -50,40 +53,28 @@ async def __do_successful_user_step( async def __do_sucessful_machine_selection_step( - hass: HomeAssistant, result2: FlowResult, mock_device_info: LaMarzoccoDeviceInfo + hass: HomeAssistant, result2: ConfigFlowResult ) -> None: """Successfully configure the machine selection step.""" - with patch( - "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", - return_value=True, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - CONF_HOST: "192.168.1.1", - CONF_MACHINE: mock_device_info.serial_number, - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_MACHINE: "GS012345"}, + ) - assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "GS3" - assert result3["data"] == { + assert result["title"] == "GS012345" + assert result["data"] == { **USER_INPUT, - CONF_HOST: "192.168.1.1", - CONF_MODEL: mock_device_info.model, - CONF_NAME: mock_device_info.name, - CONF_TOKEN: mock_device_info.communication_key, + CONF_TOKEN: None, } + assert result["result"].unique_id == "GS012345" async def test_form( hass: HomeAssistant, mock_cloud_client: MagicMock, - mock_device_info: LaMarzoccoDeviceInfo, - mock_setup_entry: Generator[AsyncMock], ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -93,14 +84,12 @@ async def test_form( assert result["errors"] == {} assert result["step_id"] == "user" - result2 = await __do_successful_user_step(hass, result, mock_cloud_client) - await __do_sucessful_machine_selection_step(hass, result2, mock_device_info) + result = await __do_successful_user_step(hass, result, mock_cloud_client) + await __do_sucessful_machine_selection_step(hass, result) async def test_form_abort_already_configured( hass: HomeAssistant, - mock_cloud_client: MagicMock, - mock_device_info: LaMarzoccoDeviceInfo, mock_config_entry: MockConfigEntry, ) -> None: """Test we abort if already configured.""" @@ -112,139 +101,89 @@ async def test_form_abort_already_configured( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - result2 = await hass.config_entries.flow.async_configure( + 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["step_id"] == "machine_selection" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "machine_selection" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { - CONF_HOST: "192.168.1.1", - CONF_MACHINE: mock_device_info.serial_number, + CONF_MACHINE: "GS012345", }, ) await hass.async_block_till_done() - assert result3["type"] is FlowResultType.ABORT - assert result3["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (AuthFail(""), "invalid_auth"), + (RequestNotSuccessful(""), "cannot_connect"), + ], +) async def test_form_invalid_auth( hass: HomeAssistant, - mock_device_info: LaMarzoccoDeviceInfo, mock_cloud_client: MagicMock, - mock_setup_entry: Generator[AsyncMock], + side_effect: Exception, + error: str, ) -> None: """Test invalid auth error.""" - mock_cloud_client.get_customer_fleet.side_effect = AuthFail("") + mock_cloud_client.list_things.side_effect = side_effect result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 - - # test recovery from failure - mock_cloud_client.get_customer_fleet.side_effect = None - result2 = await __do_successful_user_step(hass, result, mock_cloud_client) - await __do_sucessful_machine_selection_step(hass, result2, mock_device_info) - - -async def test_form_invalid_host( - hass: HomeAssistant, - mock_cloud_client: MagicMock, - mock_device_info: LaMarzoccoDeviceInfo, - mock_setup_entry: Generator[AsyncMock], -) -> None: - """Test invalid auth error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - result2 = 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["step_id"] == "machine_selection" - - with patch( - "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", - return_value=False, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - CONF_HOST: "192.168.1.1", - CONF_MACHINE: mock_device_info.serial_number, - }, - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == {"host": "cannot_connect"} - assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 + assert result["errors"] == {"base": error} + assert len(mock_cloud_client.list_things.mock_calls) == 1 # test recovery from failure - await __do_sucessful_machine_selection_step(hass, result2, mock_device_info) + mock_cloud_client.list_things.side_effect = None + result = await __do_successful_user_step(hass, result, mock_cloud_client) + await __do_sucessful_machine_selection_step(hass, result) -async def test_form_cannot_connect( +async def test_form_no_machines( hass: HomeAssistant, mock_cloud_client: MagicMock, - mock_device_info: LaMarzoccoDeviceInfo, - mock_setup_entry: Generator[AsyncMock], ) -> None: - """Test cannot connect error.""" + """Test we don't have any devices.""" - mock_cloud_client.get_customer_fleet.return_value = {} + original_return = mock_cloud_client.list_things.return_value + mock_cloud_client.list_things.return_value = [] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "no_machines"} - assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 - - mock_cloud_client.get_customer_fleet.side_effect = RequestNotSuccessful("") - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 2 + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_machines"} + assert len(mock_cloud_client.list_things.mock_calls) == 1 # test recovery from failure - mock_cloud_client.get_customer_fleet.side_effect = None - mock_cloud_client.get_customer_fleet.return_value = { - mock_device_info.serial_number: mock_device_info - } - result2 = await __do_successful_user_step(hass, result, mock_cloud_client) - await __do_sucessful_machine_selection_step(hass, result2, mock_device_info) + mock_cloud_client.list_things.return_value = original_return + + result = await __do_successful_user_step(hass, result, mock_cloud_client) + await __do_sucessful_machine_selection_step(hass, result) async def test_reauth_flow( @@ -261,15 +200,15 @@ async def test_reauth_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_PASSWORD: "new_password"}, ) - assert result2["type"] is FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT await hass.async_block_till_done() - assert result2["reason"] == "reauth_successful" - assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 + assert result["reason"] == "reauth_successful" + assert len(mock_cloud_client.list_things.mock_calls) == 1 assert mock_config_entry.data[CONF_PASSWORD] == "new_password" @@ -277,8 +216,6 @@ async def test_reconfigure_flow( hass: HomeAssistant, mock_cloud_client: MagicMock, mock_config_entry: MockConfigEntry, - mock_device_info: LaMarzoccoDeviceInfo, - mock_setup_entry: Generator[AsyncMock], ) -> None: """Testing reconfgure flow.""" mock_config_entry.add_to_hass(hass) @@ -288,40 +225,33 @@ async def test_reconfigure_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" - result2 = await __do_successful_user_step(hass, result, mock_cloud_client) - service_info = get_bluetooth_service_info( - mock_device_info.model, mock_device_info.serial_number - ) + result = await __do_successful_user_step(hass, result, mock_cloud_client) + service_info = get_bluetooth_service_info(ModelName.GS3_MP, "GS012345") with ( - patch( - "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", - return_value=True, - ), patch( "homeassistant.components.lamarzocco.config_flow.async_discovered_service_info", return_value=[service_info], ), ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { - CONF_HOST: "192.168.1.1", - CONF_MACHINE: mock_device_info.serial_number, + CONF_MACHINE: "GS012345", }, ) await hass.async_block_till_done() - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "bluetooth_selection" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_selection" - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_MAC: service_info.address}, ) - assert result4["type"] is FlowResultType.ABORT - assert result4["reason"] == "reconfigure_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.title == "My LaMarzocco" assert mock_config_entry.data == { @@ -330,16 +260,72 @@ async def test_reconfigure_flow( } +@pytest.mark.parametrize( + "discovered", + [ + [], + [ + BluetoothServiceInfo( + name="SomeDevice", + address="aa:bb:cc:dd:ee:ff", + rssi=-63, + manufacturer_data={}, + service_data={}, + service_uuids=[], + source="local", + ) + ], + ], +) +async def test_reconfigure_flow_no_machines( + hass: HomeAssistant, + mock_cloud_client: MagicMock, + mock_config_entry: MockConfigEntry, + discovered: list[BluetoothServiceInfo], +) -> None: + """Testing reconfgure flow.""" + mock_config_entry.add_to_hass(hass) + + data = deepcopy(dict(mock_config_entry.data)) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await __do_successful_user_step(hass, result, mock_cloud_client) + + with ( + patch( + "homeassistant.components.lamarzocco.config_flow.async_discovered_service_info", + return_value=discovered, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_MACHINE: "GS012345", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert mock_config_entry.title == "My LaMarzocco" + assert CONF_MAC not in mock_config_entry.data + assert dict(mock_config_entry.data) == data + + async def test_bluetooth_discovery( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_cloud_client: MagicMock, - mock_setup_entry: Generator[AsyncMock], ) -> None: """Test bluetooth discovery.""" service_info = get_bluetooth_service_info( - mock_lamarzocco.model, mock_lamarzocco.serial_number + ModelName.GS3_MP, mock_lamarzocco.serial_number ) + mock_cloud_client.list_things.return_value[0].ble_auth_token = "dummyToken" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info ) @@ -347,52 +333,30 @@ async def test_bluetooth_discovery( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "machine_selection" + assert result["type"] is FlowResultType.CREATE_ENTRY - assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 - with patch( - "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", - return_value=True, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - CONF_HOST: "192.168.1.1", - }, - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.CREATE_ENTRY - - assert result3["title"] == "GS3" - assert result3["data"] == { + assert result["title"] == "GS012345" + assert result["data"] == { **USER_INPUT, - CONF_HOST: "192.168.1.1", - CONF_MACHINE: mock_lamarzocco.serial_number, - CONF_NAME: "GS3", CONF_MAC: "aa:bb:cc:dd:ee:ff", - CONF_MODEL: mock_lamarzocco.model, - CONF_TOKEN: "token", + CONF_TOKEN: "dummyToken", } async def test_bluetooth_discovery_already_configured( hass: HomeAssistant, mock_lamarzocco: MagicMock, - mock_cloud_client: MagicMock, - mock_setup_entry: Generator[AsyncMock], mock_config_entry: MockConfigEntry, ) -> None: """Test bluetooth discovery.""" mock_config_entry.add_to_hass(hass) service_info = get_bluetooth_service_info( - mock_lamarzocco.model, mock_lamarzocco.serial_number + ModelName.GS3_MP, mock_lamarzocco.serial_number ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info @@ -405,12 +369,10 @@ async def test_bluetooth_discovery_errors( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_cloud_client: MagicMock, - mock_device_info: LaMarzoccoDeviceInfo, - mock_setup_entry: Generator[AsyncMock], ) -> None: """Test bluetooth discovery errors.""" service_info = get_bluetooth_service_info( - mock_lamarzocco.model, mock_lamarzocco.serial_number + ModelName.GS3_MP, mock_lamarzocco.serial_number ) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -421,62 +383,36 @@ async def test_bluetooth_discovery_errors( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - mock_cloud_client.get_customer_fleet.return_value = {"GS98765", ""} - result2 = await hass.config_entries.flow.async_configure( + original_return = deepcopy(mock_cloud_client.list_things.return_value) + mock_cloud_client.list_things.return_value[0].serial_number = "GS98765" + + result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "machine_not_found"} - assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "machine_not_found"} + assert len(mock_cloud_client.list_things.mock_calls) == 1 - mock_cloud_client.get_customer_fleet.return_value = { - mock_device_info.serial_number: mock_device_info - } - result2 = await hass.config_entries.flow.async_configure( + mock_cloud_client.list_things.return_value = original_return + result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "machine_selection" - assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 2 - with patch( - "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", - return_value=True, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - CONF_HOST: "192.168.1.1", - }, - ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY - assert result3["type"] is FlowResultType.CREATE_ENTRY - - assert result3["title"] == "GS3" - assert result3["data"] == { + assert result["title"] == "GS012345" + assert result["data"] == { **USER_INPUT, - CONF_HOST: "192.168.1.1", - CONF_MACHINE: mock_lamarzocco.serial_number, - CONF_NAME: "GS3", CONF_MAC: "aa:bb:cc:dd:ee:ff", - CONF_MODEL: mock_lamarzocco.model, - CONF_TOKEN: "token", + CONF_TOKEN: None, } -@pytest.mark.parametrize( - "device_fixture", - [MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI, MachineModel.GS3_AV], -) async def test_dhcp_discovery( hass: HomeAssistant, mock_lamarzocco: MagicMock, - mock_cloud_client: MagicMock, - mock_device_info: LaMarzoccoDeviceInfo, - mock_setup_entry: Generator[AsyncMock], ) -> None: """Test dhcp discovery.""" @@ -493,30 +429,20 @@ async def test_dhcp_discovery( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - with patch( - "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", - return_value=True, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT, - ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["data"] == { - **USER_INPUT, - CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", - CONF_HOST: "192.168.1.42", - CONF_MACHINE: mock_lamarzocco.serial_number, - CONF_MODEL: mock_device_info.model, - CONF_NAME: mock_device_info.name, - CONF_TOKEN: mock_device_info.communication_key, - } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + **USER_INPUT, + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_TOKEN: None, + } async def test_dhcp_discovery_abort_on_hostname_changed( hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_cloud_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test dhcp discovery aborts when hostname was changed manually.""" @@ -537,11 +463,9 @@ async def test_dhcp_discovery_abort_on_hostname_changed( async def test_dhcp_already_configured_and_update( hass: HomeAssistant, mock_lamarzocco: MagicMock, - mock_cloud_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test discovered IP address change.""" - old_ip = mock_config_entry.data[CONF_HOST] old_address = mock_config_entry.data[CONF_ADDRESS] mock_config_entry.add_to_hass(hass) @@ -557,18 +481,13 @@ async def test_dhcp_already_configured_and_update( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - assert mock_config_entry.data[CONF_HOST] != old_ip - assert mock_config_entry.data[CONF_HOST] == "192.168.1.42" - assert mock_config_entry.data[CONF_ADDRESS] != old_address assert mock_config_entry.data[CONF_ADDRESS] == "aa:bb:cc:dd:ee:ff" async def test_options_flow( hass: HomeAssistant, - mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, - mock_setup_entry: Generator[AsyncMock], ) -> None: """Test options flow.""" await async_init_integration(hass, mock_config_entry) @@ -579,7 +498,7 @@ async def test_options_flow( 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_USE_BLUETOOTH: False, @@ -587,7 +506,7 @@ async def test_options_flow( ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { CONF_USE_BLUETOOTH: False, } diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index a9a3b9f23e1..31510ad1426 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -1,11 +1,10 @@ """Test initialization of lamarzocco.""" -from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch -from freezegun.api import FrozenDateTimeFactory -from pylamarzocco.const import FirmwareType, MachineModel +from pylamarzocco.const import FirmwareType, ModelName from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful +from pylamarzocco.models import WebSocketDetails import pytest from syrupy import SnapshotAssertion @@ -13,6 +12,7 @@ from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE from homeassistant.components.lamarzocco.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( + CONF_ADDRESS, CONF_HOST, CONF_MAC, CONF_MODEL, @@ -29,13 +29,12 @@ from homeassistant.helpers import ( from . import USER_INPUT, async_init_integration, get_bluetooth_service_info -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_lamarzocco: MagicMock, ) -> None: """Test loading and unloading the integration.""" await async_init_integration(hass, mock_config_entry) @@ -54,25 +53,50 @@ async def test_config_entry_not_ready( mock_lamarzocco: MagicMock, ) -> None: """Test the La Marzocco configuration entry not ready.""" - mock_lamarzocco.get_config.side_effect = RequestNotSuccessful("") + mock_lamarzocco.websocket.connected = False + mock_lamarzocco.get_dashboard.side_effect = RequestNotSuccessful("") await async_init_integration(hass, mock_config_entry) - assert len(mock_lamarzocco.get_config.mock_calls) == 1 + assert len(mock_lamarzocco.get_dashboard.mock_calls) == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + (AuthFail(""), ConfigEntryState.SETUP_ERROR), + (RequestNotSuccessful(""), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_get_settings_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_cloud_client: MagicMock, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test error during initial settings get.""" + mock_cloud_client.get_thing_settings.side_effect = side_effect + + await async_init_integration(hass, mock_config_entry) + + assert len(mock_cloud_client.get_thing_settings.mock_calls) == 1 + assert mock_config_entry.state is expected_state + + async def test_invalid_auth( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_lamarzocco: MagicMock, ) -> None: """Test auth error during setup.""" - mock_lamarzocco.get_config.side_effect = AuthFail("") + mock_lamarzocco.websocket.connected = False + mock_lamarzocco.get_dashboard.side_effect = AuthFail("") await async_init_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR - assert len(mock_lamarzocco.get_config.mock_calls) == 1 + assert len(mock_lamarzocco.get_dashboard.mock_calls) == 1 flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -86,37 +110,52 @@ async def test_invalid_auth( assert flow["context"].get("entry_id") == mock_config_entry.entry_id -async def test_v1_migration( +async def test_v1_migration_fails( hass: HomeAssistant, - mock_cloud_client: MagicMock, mock_lamarzocco: MagicMock, ) -> None: """Test v1 -> v2 Migration.""" - common_data = { - **USER_INPUT, - CONF_HOST: "host", - CONF_MAC: "aa:bb:cc:dd:ee:ff", - } entry_v1 = MockConfigEntry( domain=DOMAIN, version=1, unique_id=mock_lamarzocco.serial_number, - data={ - **common_data, - CONF_MACHINE: mock_lamarzocco.serial_number, - }, + data={}, ) entry_v1.add_to_hass(hass) await hass.config_entries.async_setup(entry_v1.entry_id) await hass.async_block_till_done() - assert entry_v1.version == 2 - assert dict(entry_v1.data) == { - **common_data, - CONF_NAME: "GS3", - CONF_MODEL: mock_lamarzocco.model, - CONF_TOKEN: "token", + assert entry_v1.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_v2_migration( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, +) -> None: + """Test v2 -> v3 Migration.""" + + entry_v2 = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id=mock_lamarzocco.serial_number, + data={ + **USER_INPUT, + CONF_HOST: "192.168.1.24", + CONF_NAME: "La Marzocco", + CONF_MODEL: ModelName.GS3_MP.value, + CONF_MAC: "aa:bb:cc:dd:ee:ff", + }, + ) + entry_v2.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry_v2.entry_id) + assert entry_v2.state is ConfigEntryState.LOADED + assert entry_v2.version == 3 + assert dict(entry_v2.data) == { + **USER_INPUT, + CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_TOKEN: None, } @@ -128,28 +167,28 @@ async def test_migration_errors( ) -> None: """Test errors during migration.""" - mock_cloud_client.get_customer_fleet.side_effect = RequestNotSuccessful("Error") + mock_cloud_client.list_things.side_effect = RequestNotSuccessful("Error") - entry_v1 = MockConfigEntry( + entry_v2 = MockConfigEntry( domain=DOMAIN, - version=1, + version=2, unique_id=mock_lamarzocco.serial_number, data={ **USER_INPUT, CONF_MACHINE: mock_lamarzocco.serial_number, }, ) - entry_v1.add_to_hass(hass) + entry_v2.add_to_hass(hass) - assert not await hass.config_entries.async_setup(entry_v1.entry_id) - assert entry_v1.state is ConfigEntryState.MIGRATION_ERROR + assert not await hass.config_entries.async_setup(entry_v2.entry_id) + assert entry_v2.state is ConfigEntryState.MIGRATION_ERROR async def test_config_flow_entry_migration_downgrade( hass: HomeAssistant, ) -> None: """Test that config entry fails setup if the version is from the future.""" - entry = MockConfigEntry(domain=DOMAIN, version=3) + entry = MockConfigEntry(domain=DOMAIN, version=4) entry.add_to_hass(hass) assert not await hass.config_entries.async_setup(entry.entry_id) @@ -159,12 +198,14 @@ async def test_bluetooth_is_set_from_discovery( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_lamarzocco: MagicMock, + mock_cloud_client: MagicMock, ) -> None: """Check we can fill a device from discovery info.""" service_info = get_bluetooth_service_info( - mock_lamarzocco.model, mock_lamarzocco.serial_number + ModelName.GS3_MP, mock_lamarzocco.serial_number ) + mock_cloud_client.get_thing_settings.return_value.ble_auth_token = "token" with ( patch( "homeassistant.components.lamarzocco.async_discovered_service_info", @@ -174,17 +215,15 @@ async def test_bluetooth_is_set_from_discovery( "homeassistant.components.lamarzocco.LaMarzoccoMachine" ) as mock_machine_class, ): - mock_machine = MagicMock() - mock_machine.get_firmware = AsyncMock() - mock_machine.firmware = mock_lamarzocco.firmware - mock_machine_class.return_value = mock_machine + mock_machine_class.return_value = mock_lamarzocco await async_init_integration(hass, mock_config_entry) discovery.assert_called_once() - assert mock_machine_class.call_count == 2 + assert mock_machine_class.call_count == 1 _, kwargs = mock_machine_class.call_args assert kwargs["bluetooth_client"] is not None - assert mock_config_entry.data[CONF_NAME] == service_info.name + assert mock_config_entry.data[CONF_MAC] == service_info.address + assert mock_config_entry.data[CONF_TOKEN] == "token" async def test_websocket_closed_on_unload( @@ -193,34 +232,37 @@ async def test_websocket_closed_on_unload( mock_lamarzocco: MagicMock, ) -> None: """Test the websocket is closed on unload.""" - with patch( - "homeassistant.components.lamarzocco.LaMarzoccoLocalClient", - autospec=True, - ) as local_client: - client = local_client.return_value - client.websocket = AsyncMock() + mock_disconnect_callback = AsyncMock() + mock_websocket = MagicMock() + mock_websocket.closed = True - await async_init_integration(hass, mock_config_entry) - mock_lamarzocco.websocket_connect.assert_called_once() + mock_lamarzocco.websocket = WebSocketDetails( + mock_websocket, mock_disconnect_callback + ) - client.websocket.closed = False - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() - client.websocket.close.assert_called_once() + await async_init_integration(hass, mock_config_entry) + mock_lamarzocco.connect_dashboard_websocket.assert_called_once() + mock_websocket.closed = False + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + mock_disconnect_callback.assert_called_once() @pytest.mark.parametrize( - ("version", "issue_exists"), [("v3.5-rc6", False), ("v3.3-rc4", True)] + ("version", "issue_exists"), [("v3.5-rc6", True), ("v5.0.9", False)] ) async def test_gateway_version_issue( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_lamarzocco: MagicMock, + mock_cloud_client: MagicMock, version: str, issue_exists: bool, ) -> None: """Make sure we get the issue for certain gateway firmware versions.""" - mock_lamarzocco.firmware[FirmwareType.GATEWAY].current_version = version + mock_cloud_client.get_thing_settings.return_value.firmwares[ + FirmwareType.GATEWAY + ].build_version = version await async_init_integration(hass, mock_config_entry) @@ -229,34 +271,33 @@ async def test_gateway_version_issue( assert (issue is not None) == issue_exists -async def test_conf_host_removed_for_new_gateway( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_lamarzocco: MagicMock, -) -> None: - """Make sure we get the issue for certain gateway firmware versions.""" - mock_lamarzocco.firmware[FirmwareType.GATEWAY].current_version = "v5.0.9" - - await async_init_integration(hass, mock_config_entry) - - assert CONF_HOST not in mock_config_entry.data - - async def test_device( hass: HomeAssistant, mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test the device.""" - + mock_config_entry = MockConfigEntry( + title="My LaMarzocco", + domain=DOMAIN, + version=3, + data=USER_INPUT + | { + CONF_ADDRESS: "00:00:00:00:00:00", + CONF_TOKEN: "token", + CONF_MAC: "aa:bb:cc:dd:ee:ff", + }, + unique_id=mock_lamarzocco.serial_number, + ) await async_init_integration(hass, mock_config_entry) hass.config_entries.async_update_entry( mock_config_entry, - data={**mock_config_entry.data, CONF_MAC: "aa:bb:cc:dd:ee:ff"}, + data={ + **mock_config_entry.data, + }, ) state = hass.states.get(f"switch.{mock_lamarzocco.serial_number}") @@ -269,49 +310,3 @@ async def test_device( device = device_registry.async_get(entry.device_id) assert device assert device == snapshot - - -@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) -async def test_scale_device( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, - device_registry: dr.DeviceRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test the device.""" - - await async_init_integration(hass, mock_config_entry) - - device = device_registry.async_get_device( - identifiers={(DOMAIN, mock_lamarzocco.config.scale.address)} - ) - assert device - assert device == snapshot - - -@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) -async def test_remove_stale_scale( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, - device_registry: dr.DeviceRegistry, - freezer: FrozenDateTimeFactory, -) -> None: - """Ensure stale scale is cleaned up.""" - - await async_init_integration(hass, mock_config_entry) - - scale_address = mock_lamarzocco.config.scale.address - - device = device_registry.async_get_device(identifiers={(DOMAIN, scale_address)}) - assert device - - mock_lamarzocco.config.scale = None - - freezer.tick(timedelta(minutes=10)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - device = device_registry.async_get_device(identifiers={(DOMAIN, scale_address)}) - assert device is None diff --git a/tests/components/lamarzocco/test_number.py b/tests/components/lamarzocco/test_number.py index 65c5e264f22..e4be04f4ce4 100644 --- a/tests/components/lamarzocco/test_number.py +++ b/tests/components/lamarzocco/test_number.py @@ -1,19 +1,15 @@ """Tests for the La Marzocco number entities.""" -from datetime import timedelta from typing import Any from unittest.mock import MagicMock -from freezegun.api import FrozenDateTimeFactory from pylamarzocco.const import ( - KEYS_PER_MODEL, - BoilerType, - MachineModel, - PhysicalKey, - PrebrewMode, + ModelName, + PreExtractionMode, + SmartStandByType, + WidgetType, ) from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.models import LaMarzoccoScale import pytest from syrupy import SnapshotAssertion @@ -22,14 +18,14 @@ from homeassistant.components.number import ( DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from . import async_init_integration -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry @pytest.mark.parametrize( @@ -38,14 +34,14 @@ from tests.common import MockConfigEntry, async_fire_time_changed ( "coffee_target_temperature", 94, - "set_temp", - {"boiler": BoilerType.COFFEE, "temperature": 94}, + "set_coffee_target_temperature", + {"temperature": 94}, ), ( "smart_standby_time", 23, "set_smart_standby", - {"enabled": True, "mode": "LastBrewing", "minutes": 23}, + {"enabled": True, "mode": SmartStandByType.POWER_ON, "minutes": 23}, ), ], ) @@ -94,38 +90,22 @@ async def test_general_numbers( mock_func.assert_called_once_with(**kwargs) -@pytest.mark.parametrize("device_fixture", [MachineModel.GS3_AV, MachineModel.GS3_MP]) -@pytest.mark.parametrize( - ("entity_name", "value", "func_name", "kwargs"), - [ - ( - "steam_target_temperature", - 131, - "set_temp", - {"boiler": BoilerType.STEAM, "temperature": 131}, - ), - ("tea_water_duration", 15, "set_dose_tea_water", {"dose": 15}), - ], -) -async def test_gs3_exclusive( +@pytest.mark.parametrize("device_fixture", [ModelName.LINEA_MICRA]) +async def test_preinfusion( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, - entity_name: str, - value: float, - func_name: str, - kwargs: dict[str, float], ) -> None: - """Test exclusive entities for GS3 AV/MP.""" + """Test preinfusion number.""" + await async_init_integration(hass, mock_config_entry) serial_number = mock_lamarzocco.serial_number + entity_id = f"number.{serial_number}_preinfusion_time" - func = getattr(mock_lamarzocco, func_name) + state = hass.states.get(entity_id) - state = hass.states.get(f"number.{serial_number}_{entity_name}") assert state assert state == snapshot @@ -134,97 +114,49 @@ async def test_gs3_exclusive( assert entry.device_id assert entry == snapshot - device = device_registry.async_get(entry.device_id) - assert device - # service call await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, { - ATTR_ENTITY_ID: f"number.{serial_number}_{entity_name}", - ATTR_VALUE: value, + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 5.3, }, blocking=True, ) - assert len(func.mock_calls) == 1 - func.assert_called_once_with(**kwargs) + mock_lamarzocco.set_pre_extraction_times.assert_called_once_with( + seconds_off=5.3, + seconds_on=0, + ) -@pytest.mark.parametrize( - "device_fixture", [MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI] -) -async def test_gs3_exclusive_none( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure GS3 exclusive is None for unsupported models.""" - await async_init_integration(hass, mock_config_entry) - ENTITIES = ("steam_target_temperature", "tea_water_duration") - - serial_number = mock_lamarzocco.serial_number - for entity in ENTITIES: - state = hass.states.get(f"number.{serial_number}_{entity}") - assert state is None - - -@pytest.mark.parametrize( - "device_fixture", [MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI] -) -@pytest.mark.parametrize( - ("entity_name", "function_name", "prebrew_mode", "value", "kwargs"), - [ - ( - "prebrew_off_time", - "set_prebrew_time", - PrebrewMode.PREBREW, - 6, - {"prebrew_off_time": 6.0, "key": PhysicalKey.A}, - ), - ( - "prebrew_on_time", - "set_prebrew_time", - PrebrewMode.PREBREW, - 6, - {"prebrew_on_time": 6.0, "key": PhysicalKey.A}, - ), - ( - "preinfusion_time", - "set_preinfusion_time", - PrebrewMode.PREINFUSION, - 7, - {"preinfusion_time": 7.0, "key": PhysicalKey.A}, - ), - ], -) -async def test_pre_brew_infusion_numbers( +@pytest.mark.parametrize("device_fixture", [ModelName.LINEA_MICRA]) +async def test_prebrew_on( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, - entity_name: str, - function_name: str, - prebrew_mode: PrebrewMode, - value: float, - kwargs: dict[str, float], ) -> None: - """Test the La Marzocco prebrew/-infusion sensors.""" + """Test prebrew on number.""" + + mock_lamarzocco.dashboard.config[ + WidgetType.CM_PRE_BREWING + ].mode = PreExtractionMode.PREBREWING - mock_lamarzocco.config.prebrew_mode = prebrew_mode await async_init_integration(hass, mock_config_entry) - serial_number = mock_lamarzocco.serial_number + entity_id = f"number.{serial_number}_prebrew_on_time" - state = hass.states.get(f"number.{serial_number}_{entity_name}") + state = hass.states.get(entity_id) assert state assert state == snapshot entry = entity_registry.async_get(state.entity_id) assert entry + assert entry.device_id assert entry == snapshot # service call @@ -232,178 +164,64 @@ async def test_pre_brew_infusion_numbers( NUMBER_DOMAIN, SERVICE_SET_VALUE, { - ATTR_ENTITY_ID: f"number.{serial_number}_{entity_name}", - ATTR_VALUE: value, + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 5.3, }, blocking=True, ) - function = getattr(mock_lamarzocco, function_name) - function.assert_called_once_with(**kwargs) - - -@pytest.mark.parametrize( - "device_fixture", [MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI] -) -@pytest.mark.parametrize( - ("prebrew_mode", "entity", "unavailable"), - [ - ( - PrebrewMode.PREBREW, - ("prebrew_off_time", "prebrew_on_time"), - ("preinfusion_time",), - ), - ( - PrebrewMode.PREINFUSION, - ("preinfusion_time",), - ("prebrew_off_time", "prebrew_on_time"), - ), - ], -) -async def test_pre_brew_infusion_numbers_unavailable( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, - prebrew_mode: PrebrewMode, - entity: tuple[str, ...], - unavailable: tuple[str, ...], -) -> None: - """Test entities are unavailable depending on selected state.""" - - mock_lamarzocco.config.prebrew_mode = prebrew_mode - await async_init_integration(hass, mock_config_entry) - - serial_number = mock_lamarzocco.serial_number - for entity_name in entity: - state = hass.states.get(f"number.{serial_number}_{entity_name}") - assert state - assert state.state != STATE_UNAVAILABLE - - for entity_name in unavailable: - state = hass.states.get(f"number.{serial_number}_{entity_name}") - assert state - assert state.state == STATE_UNAVAILABLE - - -@pytest.mark.parametrize("device_fixture", [MachineModel.GS3_AV]) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize( - ("entity_name", "value", "prebrew_mode", "function_name", "kwargs"), - [ - ( - "prebrew_off_time", - 6, - PrebrewMode.PREBREW, - "set_prebrew_time", - {"prebrew_off_time": 6.0}, - ), - ( - "prebrew_on_time", - 6, - PrebrewMode.PREBREW, - "set_prebrew_time", - {"prebrew_on_time": 6.0}, - ), - ( - "preinfusion_time", - 7, - PrebrewMode.PREINFUSION, - "set_preinfusion_time", - {"preinfusion_time": 7.0}, - ), - ("dose", 6, PrebrewMode.DISABLED, "set_dose", {"dose": 6}), - ], -) -async def test_pre_brew_infusion_key_numbers( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, - entity_name: str, - value: float, - prebrew_mode: PrebrewMode, - function_name: str, - kwargs: dict[str, float], -) -> None: - """Test the La Marzocco number sensors for GS3AV model.""" - - mock_lamarzocco.config.prebrew_mode = prebrew_mode - await async_init_integration(hass, mock_config_entry) - - serial_number = mock_lamarzocco.serial_number - - func = getattr(mock_lamarzocco, function_name) - - state = hass.states.get(f"number.{serial_number}_{entity_name}") - assert state is None - - for key in PhysicalKey: - state = hass.states.get(f"number.{serial_number}_{entity_name}_key_{key}") - assert state - assert state == snapshot(name=f"{serial_number}_{entity_name}_key_{key}-state") - - # service call - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: f"number.{serial_number}_{entity_name}_key_{key}", - ATTR_VALUE: value, - }, - blocking=True, - ) - - kwargs["key"] = key - - assert len(func.mock_calls) == key.value - func.assert_called_with(**kwargs) - - -@pytest.mark.parametrize("device_fixture", [MachineModel.GS3_AV]) -async def test_disabled_entites( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test the La Marzocco prebrew/-infusion sensors for GS3AV model.""" - await async_init_integration(hass, mock_config_entry) - ENTITIES = ( - "prebrew_off_time", - "prebrew_on_time", - "preinfusion_time", - "set_dose", + mock_lamarzocco.set_pre_extraction_times.assert_called_once_with( + seconds_on=5.3, + seconds_off=mock_lamarzocco.dashboard.config[WidgetType.CM_PRE_BREWING] + .times.pre_brewing[0] + .seconds.seconds_out, ) - serial_number = mock_lamarzocco.serial_number - for entity_name in ENTITIES: - for key in PhysicalKey: - state = hass.states.get(f"number.{serial_number}_{entity_name}_key_{key}") - assert state is None - - -@pytest.mark.parametrize( - "device_fixture", - [MachineModel.GS3_MP, MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI], -) -async def test_not_existing_key_entities( +@pytest.mark.parametrize("device_fixture", [ModelName.LINEA_MICRA]) +async def test_prebrew_off( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Assert not existing key entities.""" + """Test prebrew off number.""" + mock_lamarzocco.dashboard.config[ + WidgetType.CM_PRE_BREWING + ].mode = PreExtractionMode.PREBREWING + await async_init_integration(hass, mock_config_entry) serial_number = mock_lamarzocco.serial_number + entity_id = f"number.{serial_number}_prebrew_off_time" - for entity in ( - "prebrew_off_time", - "prebrew_on_time", - "preinfusion_time", - "set_dose", - ): - for key in range(1, KEYS_PER_MODEL[MachineModel.GS3_AV] + 1): - state = hass.states.get(f"number.{serial_number}_{entity}_key_{key}") - assert state is None + state = hass.states.get(entity_id) + + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot + + # service call + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 7, + }, + blocking=True, + ) + + mock_lamarzocco.set_pre_extraction_times.assert_called_once_with( + seconds_off=7, + seconds_on=mock_lamarzocco.dashboard.config[WidgetType.CM_PRE_BREWING] + .times.pre_brewing[0] + .seconds.seconds_in, + ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -419,7 +237,9 @@ async def test_number_error( state = hass.states.get(f"number.{serial_number}_coffee_target_temperature") assert state - mock_lamarzocco.set_temp.side_effect = RequestNotSuccessful("Boom") + mock_lamarzocco.set_coffee_target_temperature.side_effect = RequestNotSuccessful( + "Boom" + ) with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( NUMBER_DOMAIN, @@ -431,107 +251,3 @@ async def test_number_error( blocking=True, ) assert exc_info.value.translation_key == "number_exception" - - state = hass.states.get(f"number.{serial_number}_dose_key_1") - assert state - - mock_lamarzocco.set_dose.side_effect = RequestNotSuccessful("Boom") - with pytest.raises(HomeAssistantError) as exc_info: - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: f"number.{serial_number}_dose_key_1", - ATTR_VALUE: 99, - }, - blocking=True, - ) - assert exc_info.value.translation_key == "number_exception_key" - - -@pytest.mark.parametrize("physical_key", [PhysicalKey.A, PhysicalKey.B]) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) -async def test_set_target( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, - physical_key: PhysicalKey, -) -> None: - """Test the La Marzocco set target sensors.""" - - await async_init_integration(hass, mock_config_entry) - - entity_name = f"number.lmz_123a45_brew_by_weight_target_{int(physical_key)}" - - state = hass.states.get(entity_name) - - assert state - assert state == snapshot - - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry == snapshot - - # service call - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: entity_name, - ATTR_VALUE: 42, - }, - blocking=True, - ) - - mock_lamarzocco.set_bbw_recipe_target.assert_called_once_with(physical_key, 42) - - -@pytest.mark.parametrize( - "device_fixture", - [MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA], -) -async def test_other_models_no_scale_set_target( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, -) -> None: - """Ensure the other models don't have a set target numbers.""" - await async_init_integration(hass, mock_config_entry) - - for i in range(1, 3): - state = hass.states.get(f"number.lmz_123a45_brew_by_weight_target_{i}") - assert state is None - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) -async def test_set_target_on_new_scale_added( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, -) -> None: - """Ensure the set target numbers for a new scale are added automatically.""" - - mock_lamarzocco.config.scale = None - await async_init_integration(hass, mock_config_entry) - - for i in range(1, 3): - state = hass.states.get(f"number.scale_123a45_brew_by_weight_target_{i}") - assert state is None - - mock_lamarzocco.config.scale = LaMarzoccoScale( - connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50 - ) - - freezer.tick(timedelta(minutes=10)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - for i in range(1, 3): - state = hass.states.get(f"number.scale_123a45_brew_by_weight_target_{i}") - assert state diff --git a/tests/components/lamarzocco/test_select.py b/tests/components/lamarzocco/test_select.py index 3bfb579e6d4..78cb9e313dd 100644 --- a/tests/components/lamarzocco/test_select.py +++ b/tests/components/lamarzocco/test_select.py @@ -1,18 +1,14 @@ """Tests for the La Marzocco select entities.""" -from datetime import timedelta from unittest.mock import MagicMock -from freezegun.api import FrozenDateTimeFactory from pylamarzocco.const import ( - MachineModel, - PhysicalKey, - PrebrewMode, - SmartStandbyMode, - SteamLevel, + ModelName, + PreExtractionMode, + SmartStandByType, + SteamTargetLevel, ) from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.models import LaMarzoccoScale import pytest from syrupy import SnapshotAssertion @@ -26,15 +22,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import async_init_integration - -from tests.common import MockConfigEntry, async_fire_time_changed - pytest.mark.usefixtures("init_integration") @pytest.mark.usefixtures("init_integration") -@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MICRA]) +@pytest.mark.parametrize("device_fixture", [ModelName.LINEA_MICRA]) async def test_steam_boiler_level( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -65,12 +57,14 @@ async def test_steam_boiler_level( blocking=True, ) - mock_lamarzocco.set_steam_level.assert_called_once_with(level=SteamLevel.LEVEL_2) + mock_lamarzocco.set_steam_level.assert_called_once_with( + level=SteamTargetLevel.LEVEL_2 + ) @pytest.mark.parametrize( "device_fixture", - [MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MINI], + [ModelName.GS3_AV, ModelName.GS3_MP, ModelName.LINEA_MINI], ) async def test_steam_boiler_level_none( hass: HomeAssistant, @@ -86,7 +80,7 @@ async def test_steam_boiler_level_none( @pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( "device_fixture", - [MachineModel.LINEA_MICRA, MachineModel.GS3_AV, MachineModel.LINEA_MINI], + [ModelName.LINEA_MICRA, ModelName.GS3_AV, ModelName.LINEA_MINI], ) async def test_pre_brew_infusion_select( hass: HomeAssistant, @@ -118,19 +112,21 @@ async def test_pre_brew_infusion_select( blocking=True, ) - mock_lamarzocco.set_prebrew_mode.assert_called_once_with(mode=PrebrewMode.PREBREW) + mock_lamarzocco.set_pre_extraction_mode.assert_called_once_with( + mode=PreExtractionMode.PREBREWING + ) @pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( "device_fixture", - [MachineModel.GS3_MP], + [ModelName.GS3_MP], ) async def test_pre_brew_infusion_select_none( hass: HomeAssistant, mock_lamarzocco: MagicMock, ) -> None: - """Ensure the La Marzocco Steam Level Select is not created for non-Micra models.""" + """Ensure GS3 MP has no prebrew models.""" serial_number = mock_lamarzocco.serial_number state = hass.states.get(f"select.{serial_number}_prebrew_infusion_mode") @@ -162,13 +158,13 @@ async def test_smart_standby_mode( SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: f"select.{serial_number}_smart_standby_mode", - ATTR_OPTION: "power_on", + ATTR_OPTION: "last_brewing", }, blocking=True, ) mock_lamarzocco.set_smart_standby.assert_called_once_with( - enabled=True, mode=SmartStandbyMode.POWER_ON, minutes=10 + enabled=True, mode=SmartStandByType.LAST_BREW, minutes=10 ) @@ -183,7 +179,7 @@ async def test_select_errors( state = hass.states.get(f"select.{serial_number}_prebrew_infusion_mode") assert state - mock_lamarzocco.set_prebrew_mode.side_effect = RequestNotSuccessful("Boom") + mock_lamarzocco.set_pre_extraction_mode.side_effect = RequestNotSuccessful("Boom") # Test setting invalid option with pytest.raises(HomeAssistantError) as exc_info: @@ -197,77 +193,3 @@ async def test_select_errors( blocking=True, ) assert exc_info.value.translation_key == "select_option_error" - - -@pytest.mark.usefixtures("init_integration") -@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) -async def test_active_bbw_recipe( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_lamarzocco: MagicMock, - snapshot: SnapshotAssertion, -) -> None: - """Test the La Marzocco active bbw recipe select.""" - - state = hass.states.get("select.lmz_123a45_active_brew_by_weight_recipe") - - assert state - assert state == snapshot - - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry == snapshot - - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.lmz_123a45_active_brew_by_weight_recipe", - ATTR_OPTION: "b", - }, - blocking=True, - ) - - mock_lamarzocco.set_active_bbw_recipe.assert_called_once_with(PhysicalKey.B) - - -@pytest.mark.usefixtures("init_integration") -@pytest.mark.parametrize( - "device_fixture", - [MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA], -) -async def test_other_models_no_active_bbw_select( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, -) -> None: - """Ensure the other models don't have a battery sensor.""" - - state = hass.states.get("select.lmz_123a45_active_brew_by_weight_recipe") - assert state is None - - -@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) -async def test_active_bbw_select_on_new_scale_added( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, -) -> None: - """Ensure the active bbw select for a new scale is added automatically.""" - - mock_lamarzocco.config.scale = None - await async_init_integration(hass, mock_config_entry) - - state = hass.states.get("select.scale_123a45_active_brew_by_weight_recipe") - assert state is None - - mock_lamarzocco.config.scale = LaMarzoccoScale( - connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50 - ) - - freezer.tick(timedelta(minutes=10)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - state = hass.states.get("select.scale_123a45_active_brew_by_weight_recipe") - assert state diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py index 43a0826d551..0b050dd7788 100644 --- a/tests/components/lamarzocco/test_sensor.py +++ b/tests/components/lamarzocco/test_sensor.py @@ -1,29 +1,25 @@ """Tests for La Marzocco sensors.""" -from datetime import timedelta from unittest.mock import MagicMock, patch -from freezegun.api import FrozenDateTimeFactory -from pylamarzocco.const import MachineModel -from pylamarzocco.models import LaMarzoccoScale +from pylamarzocco.const import ModelName import pytest from syrupy import SnapshotAssertion -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import async_init_integration -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, mock_lamarzocco: MagicMock, - entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test the La Marzocco sensors.""" @@ -33,106 +29,24 @@ async def test_sensors( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_shot_timer_not_exists( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry_no_local_connection: MockConfigEntry, -) -> None: - """Test the La Marzocco shot timer doesn't exist if host not set.""" - - await async_init_integration(hass, mock_config_entry_no_local_connection) - state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_shot_timer") - assert state is None - - -async def test_shot_timer_unavailable( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test the La Marzocco brew_active becomes unavailable.""" - - mock_lamarzocco.websocket_connected = False - await async_init_integration(hass, mock_config_entry) - state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_shot_timer") - assert state - assert state.state == STATE_UNAVAILABLE - - -@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) -async def test_no_steam_linea_mini( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure Linea Mini has no steam temp.""" - await async_init_integration(hass, mock_config_entry) - - serial_number = mock_lamarzocco.serial_number - state = hass.states.get(f"sensor.{serial_number}_current_temp_steam") - assert state is None - - -@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) -async def test_scale_battery( +@pytest.mark.parametrize( + "device_fixture", + [ModelName.GS3_AV, ModelName.GS3_MP, ModelName.LINEA_MINI, ModelName.LINEA_MICRA], +) +async def test_steam_ready_entity_for_all_machines( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, ) -> None: - """Test the scale battery sensor.""" + """Test the La Marzocco steam ready sensor for all machines.""" + + serial_number = mock_lamarzocco.serial_number await async_init_integration(hass, mock_config_entry) - state = hass.states.get("sensor.lmz_123a45_battery") + state = hass.states.get(f"sensor.{serial_number}_steam_boiler_ready_time") + assert state - assert state == snapshot entry = entity_registry.async_get(state.entity_id) assert entry - assert entry.device_id - assert entry == snapshot - - -@pytest.mark.parametrize( - "device_fixture", - [MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA], -) -async def test_other_models_no_scale_battery( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, -) -> None: - """Ensure the other models don't have a battery sensor.""" - await async_init_integration(hass, mock_config_entry) - - state = hass.states.get("sensor.lmz_123a45_battery") - assert state is None - - -@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) -async def test_battery_on_new_scale_added( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, -) -> None: - """Ensure the battery sensor for a new scale is added automatically.""" - - mock_lamarzocco.config.scale = None - await async_init_integration(hass, mock_config_entry) - - state = hass.states.get("sensor.lmz_123a45_battery") - assert state is None - - mock_lamarzocco.config.scale = LaMarzoccoScale( - connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50 - ) - - freezer.tick(timedelta(minutes=10)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - state = hass.states.get("sensor.scale_123a45_battery") - assert state diff --git a/tests/components/lamarzocco/test_switch.py b/tests/components/lamarzocco/test_switch.py index d8370ad8575..b8e536e5c1b 100644 --- a/tests/components/lamarzocco/test_switch.py +++ b/tests/components/lamarzocco/test_switch.py @@ -3,6 +3,7 @@ from typing import Any from unittest.mock import MagicMock, patch +from pylamarzocco.const import SmartStandByType from pylamarzocco.exceptions import RequestNotSuccessful import pytest from syrupy import SnapshotAssertion @@ -24,7 +25,6 @@ from tests.common import MockConfigEntry, snapshot_platform async def test_switches( hass: HomeAssistant, - mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, @@ -47,7 +47,7 @@ async def test_switches( ( "_smart_standby_enabled", "set_smart_standby", - {"mode": "LastBrewing", "minutes": 10}, + {"mode": SmartStandByType.POWER_ON, "minutes": 10}, ), ], ) @@ -124,12 +124,15 @@ async def test_auto_on_off_switches( blocking=True, ) - wake_up_sleep_entry = mock_lamarzocco.config.wake_up_sleep_entries[ - wake_up_sleep_entry_id - ] + wake_up_sleep_entry = ( + mock_lamarzocco.schedule.smart_wake_up_sleep.schedules_dict[ + wake_up_sleep_entry_id + ] + ) + assert wake_up_sleep_entry wake_up_sleep_entry.enabled = False - mock_lamarzocco.set_wake_up_sleep.assert_called_with(wake_up_sleep_entry) + mock_lamarzocco.set_wakeup_schedule.assert_called_with(wake_up_sleep_entry) await hass.services.async_call( SWITCH_DOMAIN, @@ -140,7 +143,7 @@ async def test_auto_on_off_switches( blocking=True, ) wake_up_sleep_entry.enabled = True - mock_lamarzocco.set_wake_up_sleep.assert_called_with(wake_up_sleep_entry) + mock_lamarzocco.set_wakeup_schedule.assert_called_with(wake_up_sleep_entry) async def test_switch_exceptions( @@ -183,7 +186,7 @@ async def test_switch_exceptions( state = hass.states.get(f"switch.{serial_number}_auto_on_off_os2oswx") assert state - mock_lamarzocco.set_wake_up_sleep.side_effect = RequestNotSuccessful("Boom") + mock_lamarzocco.set_wakeup_schedule.side_effect = RequestNotSuccessful("Boom") with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( SWITCH_DOMAIN, diff --git a/tests/components/lamarzocco/test_update.py b/tests/components/lamarzocco/test_update.py index 4089ffa297a..3dbc5e98bee 100644 --- a/tests/components/lamarzocco/test_update.py +++ b/tests/components/lamarzocco/test_update.py @@ -1,9 +1,16 @@ """Tests for the La Marzocco Update Entities.""" -from unittest.mock import MagicMock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch -from pylamarzocco.const import FirmwareType +from pylamarzocco.const import ( + FirmwareType, + UpdateCommandStatus, + UpdateProgressInfo, + UpdateStatus, +) from pylamarzocco.exceptions import RequestNotSuccessful +from pylamarzocco.models import UpdateDetails import pytest from syrupy import SnapshotAssertion @@ -16,11 +23,21 @@ from homeassistant.helpers import entity_registry as er from . import async_init_integration from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +def mock_sleep() -> Generator[AsyncMock]: + """Mock asyncio.sleep.""" + with patch( + "homeassistant.components.lamarzocco.update.asyncio.sleep", + return_value=AsyncMock(), + ) as mock_sleep: + yield mock_sleep async def test_update( hass: HomeAssistant, - mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, @@ -31,67 +48,115 @@ async def test_update( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.parametrize( - ("entity_name", "component"), - [ - ("machine_firmware", FirmwareType.MACHINE), - ("gateway_firmware", FirmwareType.GATEWAY), - ], -) -async def test_update_entites( +async def test_update_process( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, - entity_name: str, - component: FirmwareType, + hass_ws_client: WebSocketGenerator, ) -> None: """Test the La Marzocco update entities.""" serial_number = mock_lamarzocco.serial_number + mock_lamarzocco.get_firmware.side_effect = [ + UpdateDetails( + status=UpdateStatus.TO_UPDATE, + command_status=UpdateCommandStatus.IN_PROGRESS, + progress_info=UpdateProgressInfo.STARTING_PROCESS, + progress_percentage=0, + ), + UpdateDetails( + status=UpdateStatus.UPDATED, + command_status=None, + progress_info=None, + progress_percentage=None, + ), + ] + await async_init_integration(hass, mock_config_entry) + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": f"update.{serial_number}_gateway_firmware", + } + ) + result = await client.receive_json() + assert ( + mock_lamarzocco.settings.firmwares[ + FirmwareType.GATEWAY + ].available_update.change_log + in result["result"] + ) + await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: f"update.{serial_number}_{entity_name}", + ATTR_ENTITY_ID: f"update.{serial_number}_gateway_firmware", }, blocking=True, ) - mock_lamarzocco.update_firmware.assert_called_once_with(component) + mock_lamarzocco.update_firmware.assert_called_once_with() -@pytest.mark.parametrize( - ("attr", "value"), - [ - ("side_effect", RequestNotSuccessful("Boom")), - ("return_value", False), - ], -) async def test_update_error( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, - attr: str, - value: bool | Exception, ) -> None: """Test error during update.""" await async_init_integration(hass, mock_config_entry) - state = hass.states.get(f"update.{mock_lamarzocco.serial_number}_machine_firmware") + state = hass.states.get(f"update.{mock_lamarzocco.serial_number}_gateway_firmware") assert state - setattr(mock_lamarzocco.update_firmware, attr, value) + mock_lamarzocco.update_firmware.side_effect = RequestNotSuccessful("Boom") with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: f"update.{mock_lamarzocco.serial_number}_machine_firmware", + ATTR_ENTITY_ID: f"update.{mock_lamarzocco.serial_number}_gateway_firmware", + }, + blocking=True, + ) + assert exc_info.value.translation_key == "update_failed" + + +async def test_update_times_out( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error during update.""" + mock_lamarzocco.get_firmware.return_value = UpdateDetails( + status=UpdateStatus.TO_UPDATE, + command_status=UpdateCommandStatus.IN_PROGRESS, + progress_info=UpdateProgressInfo.STARTING_PROCESS, + progress_percentage=0, + ) + await async_init_integration(hass, mock_config_entry) + + state = hass.states.get(f"update.{mock_lamarzocco.serial_number}_gateway_firmware") + assert state + + with ( + patch("homeassistant.components.lamarzocco.update.MAX_UPDATE_WAIT", 0), + pytest.raises(HomeAssistantError) as exc_info, + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: f"update.{mock_lamarzocco.serial_number}_gateway_firmware", }, blocking=True, ) diff --git a/tests/components/lcn/conftest.py b/tests/components/lcn/conftest.py index d8dee472946..e588cc7b952 100644 --- a/tests/components/lcn/conftest.py +++ b/tests/components/lcn/conftest.py @@ -88,7 +88,7 @@ def create_config_entry( title = entry_data[CONF_HOST] return MockConfigEntry( - entry_id=fixture_filename, + entry_id=fixture_filename.replace(".", "_"), domain=DOMAIN, title=title, data=entry_data, diff --git a/tests/components/lcn/fixtures/config_entry_pchk.json b/tests/components/lcn/fixtures/config_entry_pchk.json index 068b8757707..f319e37b265 100644 --- a/tests/components/lcn/fixtures/config_entry_pchk.json +++ b/tests/components/lcn/fixtures/config_entry_pchk.json @@ -27,7 +27,6 @@ { "address": [0, 7, false], "name": "Light_Output1", - "resource": "output1", "domain": "light", "domain_data": { "output": "OUTPUT1", @@ -38,7 +37,6 @@ { "address": [0, 7, false], "name": "Light_Output2", - "resource": "output2", "domain": "light", "domain_data": { "output": "OUTPUT2", @@ -49,7 +47,6 @@ { "address": [0, 7, false], "name": "Light_Relay1", - "resource": "relay1", "domain": "light", "domain_data": { "output": "RELAY1", @@ -60,7 +57,6 @@ { "address": [0, 7, false], "name": "Switch_Output1", - "resource": "output1", "domain": "switch", "domain_data": { "output": "OUTPUT1" @@ -69,7 +65,6 @@ { "address": [0, 7, false], "name": "Switch_Output2", - "resource": "output2", "domain": "switch", "domain_data": { "output": "OUTPUT2" @@ -78,7 +73,6 @@ { "address": [0, 7, false], "name": "Switch_Relay1", - "resource": "relay1", "domain": "switch", "domain_data": { "output": "RELAY1" @@ -87,7 +81,6 @@ { "address": [0, 7, false], "name": "Switch_Relay2", - "resource": "relay2", "domain": "switch", "domain_data": { "output": "RELAY2" @@ -96,7 +89,6 @@ { "address": [0, 7, false], "name": "Switch_Regulator1", - "resource": "r1varsetpoint", "domain": "switch", "domain_data": { "output": "R1VARSETPOINT" @@ -105,7 +97,6 @@ { "address": [0, 7, false], "name": "Switch_KeyLock1", - "resource": "a1", "domain": "switch", "domain_data": { "output": "A1" @@ -114,7 +105,6 @@ { "address": [0, 5, true], "name": "Switch_Group5", - "resource": "relay1", "domain": "switch", "domain_data": { "output": "RELAY1" @@ -123,7 +113,6 @@ { "address": [0, 7, false], "name": "Cover_Outputs", - "resource": "outputs", "domain": "cover", "domain_data": { "motor": "OUTPUTS", @@ -133,7 +122,6 @@ { "address": [0, 7, false], "name": "Cover_Relays", - "resource": "motor1", "domain": "cover", "domain_data": { "motor": "MOTOR1", @@ -143,12 +131,12 @@ { "address": [0, 7, false], "name": "Climate1", - "resource": "var1.r1varsetpoint", "domain": "climate", "domain_data": { "source": "VAR1", "setpoint": "R1VARSETPOINT", "lockable": true, + "target_value_locked": -1, "min_temp": 0.0, "max_temp": 40.0, "unit_of_measurement": "°C" @@ -157,7 +145,6 @@ { "address": [0, 7, false], "name": "Romantic", - "resource": "0.0", "domain": "scene", "domain_data": { "register": 0, @@ -169,7 +156,6 @@ { "address": [0, 7, false], "name": "Romantic Transition", - "resource": "0.1", "domain": "scene", "domain_data": { "register": 0, @@ -181,7 +167,6 @@ { "address": [0, 7, false], "name": "Sensor_LockRegulator1", - "resource": "r1varsetpoint", "domain": "binary_sensor", "domain_data": { "source": "R1VARSETPOINT" @@ -190,7 +175,6 @@ { "address": [0, 7, false], "name": "Binary_Sensor1", - "resource": "binsensor1", "domain": "binary_sensor", "domain_data": { "source": "BINSENSOR1" @@ -199,7 +183,6 @@ { "address": [0, 7, false], "name": "Sensor_KeyLock", - "resource": "a5", "domain": "binary_sensor", "domain_data": { "source": "A5" @@ -208,7 +191,6 @@ { "address": [0, 7, false], "name": "Sensor_Var1", - "resource": "var1", "domain": "sensor", "domain_data": { "source": "VAR1", @@ -218,7 +200,6 @@ { "address": [0, 7, false], "name": "Sensor_Setpoint1", - "resource": "r1varsetpoint", "domain": "sensor", "domain_data": { "source": "R1VARSETPOINT", @@ -228,7 +209,6 @@ { "address": [0, 7, false], "name": "Sensor_Led6", - "resource": "led6", "domain": "sensor", "domain_data": { "source": "LED6", @@ -238,7 +218,6 @@ { "address": [0, 7, false], "name": "Sensor_LogicOp1", - "resource": "logicop1", "domain": "sensor", "domain_data": { "source": "LOGICOP1", diff --git a/tests/components/lcn/fixtures/config_entry_pchk_v2_1.json b/tests/components/lcn/fixtures/config_entry_pchk_v2_1.json new file mode 100644 index 00000000000..3b4938b8600 --- /dev/null +++ b/tests/components/lcn/fixtures/config_entry_pchk_v2_1.json @@ -0,0 +1,96 @@ +{ + "host": "pchk", + "ip_address": "192.168.2.41", + "port": 4114, + "username": "lcn", + "password": "lcn", + "sk_num_tries": 0, + "dim_mode": "STEPS200", + "acknowledge": false, + "devices": [ + { + "address": [0, 7, false], + "name": "TestModule", + "hardware_serial": -1, + "software_serial": -1, + "hardware_type": -1 + } + ], + "entities": [ + { + "address": [0, 7, false], + "name": "Light_Output1", + "resource": "output1", + "domain": "light", + "domain_data": { + "output": "OUTPUT1", + "dimmable": true, + "transition": 5.0 + } + }, + { + "address": [0, 7, false], + "name": "Switch_Relay1", + "resource": "relay1", + "domain": "switch", + "domain_data": { + "output": "RELAY1" + } + }, + { + "address": [0, 7, false], + "name": "Cover_Relays", + "resource": "motor1", + "domain": "cover", + "domain_data": { + "motor": "MOTOR1", + "reverse_time": "RT1200" + } + }, + { + "address": [0, 7, false], + "name": "Climate1", + "resource": "var1.r1varsetpoint", + "domain": "climate", + "domain_data": { + "source": "VAR1", + "setpoint": "R1VARSETPOINT", + "lockable": true, + "min_temp": 0.0, + "max_temp": 40.0, + "unit_of_measurement": "°C" + } + }, + { + "address": [0, 7, false], + "name": "Romantic", + "resource": "0.0", + "domain": "scene", + "domain_data": { + "register": 0, + "scene": 0, + "outputs": ["OUTPUT1", "OUTPUT2", "RELAY1"], + "transition": 0.0 + } + }, + { + "address": [0, 7, false], + "name": "Binary_Sensor1", + "resource": "binsensor1", + "domain": "binary_sensor", + "domain_data": { + "source": "BINSENSOR1" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_Var1", + "resource": "var1", + "domain": "sensor", + "domain_data": { + "source": "VAR1", + "unit_of_measurement": "°C" + } + } + ] +} diff --git a/tests/components/lcn/snapshots/test_binary_sensor.ambr b/tests/components/lcn/snapshots/test_binary_sensor.ambr index d2d697569d1..383c9038d78 100644 --- a/tests/components/lcn/snapshots/test_binary_sensor.ambr +++ b/tests/components/lcn/snapshots/test_binary_sensor.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-binsensor1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-binsensor1', 'unit_of_measurement': None, }) # --- @@ -76,7 +76,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-a5', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-a5', 'unit_of_measurement': None, }) # --- @@ -123,7 +123,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-r1varsetpoint', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lcn/snapshots/test_climate.ambr b/tests/components/lcn/snapshots/test_climate.ambr index 81745ca8515..bd371c02492 100644 --- a/tests/components/lcn/snapshots/test_climate.ambr +++ b/tests/components/lcn/snapshots/test_climate.ambr @@ -36,7 +36,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-var1.r1varsetpoint', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lcn/snapshots/test_cover.ambr b/tests/components/lcn/snapshots/test_cover.ambr index d399626537d..3e9c4ee72eb 100644 --- a/tests/components/lcn/snapshots/test_cover.ambr +++ b/tests/components/lcn/snapshots/test_cover.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-outputs', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-outputs', 'unit_of_measurement': None, }) # --- @@ -78,7 +78,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-motor1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-motor1', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lcn/snapshots/test_init.ambr b/tests/components/lcn/snapshots/test_init.ambr index ea6267aaa0b..8d7a858cf16 100644 --- a/tests/components/lcn/snapshots/test_init.ambr +++ b/tests/components/lcn/snapshots/test_init.ambr @@ -30,7 +30,6 @@ 'transition': 5.0, }), 'name': 'Light_Output1', - 'resource': 'output1', }), ]), 'host': 'pchk', @@ -72,7 +71,6 @@ 'transition': 5.0, }), 'name': 'Light_Output1', - 'resource': 'output1', }), dict({ 'address': tuple( @@ -87,7 +85,6 @@ 'transition': 0.0, }), 'name': 'Light_Output2', - 'resource': 'output2', }), dict({ 'address': tuple( @@ -107,7 +104,6 @@ 'transition': 0.0, }), 'name': 'Romantic', - 'resource': '0.0', }), dict({ 'address': tuple( @@ -127,7 +123,134 @@ 'transition': 10.0, }), 'name': 'Romantic Transition', - 'resource': '0.1', + }), + ]), + 'host': 'pchk', + 'ip_address': '192.168.2.41', + 'password': 'lcn', + 'port': 4114, + 'sk_num_tries': 0, + 'username': 'lcn', + }) +# --- +# name: test_migrate_2_1 + dict({ + 'acknowledge': False, + 'devices': list([ + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'hardware_serial': -1, + 'hardware_type': -1, + 'name': 'TestModule', + 'software_serial': -1, + }), + ]), + 'dim_mode': 'STEPS200', + 'entities': list([ + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'light', + 'domain_data': dict({ + 'dimmable': True, + 'output': 'OUTPUT1', + 'transition': 5.0, + }), + 'name': 'Light_Output1', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'switch', + 'domain_data': dict({ + 'output': 'RELAY1', + }), + 'name': 'Switch_Relay1', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'cover', + 'domain_data': dict({ + 'motor': 'MOTOR1', + 'reverse_time': 'RT1200', + }), + 'name': 'Cover_Relays', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'climate', + 'domain_data': dict({ + 'lockable': True, + 'max_temp': 40.0, + 'min_temp': 0.0, + 'setpoint': 'R1VARSETPOINT', + 'source': 'VAR1', + 'target_value_locked': -1, + 'unit_of_measurement': '°C', + }), + 'name': 'Climate1', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'scene', + 'domain_data': dict({ + 'outputs': list([ + 'OUTPUT1', + 'OUTPUT2', + 'RELAY1', + ]), + 'register': 0, + 'scene': 0, + 'transition': 0.0, + }), + 'name': 'Romantic', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'binary_sensor', + 'domain_data': dict({ + 'source': 'BINSENSOR1', + }), + 'name': 'Binary_Sensor1', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'sensor', + 'domain_data': dict({ + 'source': 'VAR1', + 'unit_of_measurement': '°C', + }), + 'name': 'Sensor_Var1', }), ]), 'host': 'pchk', diff --git a/tests/components/lcn/snapshots/test_light.ambr b/tests/components/lcn/snapshots/test_light.ambr index 638cddc15cd..5bfd00fb0d7 100644 --- a/tests/components/lcn/snapshots/test_light.ambr +++ b/tests/components/lcn/snapshots/test_light.ambr @@ -33,7 +33,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-output1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-output1', 'unit_of_measurement': None, }) # --- @@ -90,7 +90,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-output2', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-output2', 'unit_of_measurement': None, }) # --- @@ -146,7 +146,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-relay1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-relay1', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lcn/snapshots/test_scene.ambr b/tests/components/lcn/snapshots/test_scene.ambr index a5576158621..6dac4868437 100644 --- a/tests/components/lcn/snapshots/test_scene.ambr +++ b/tests/components/lcn/snapshots/test_scene.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-0.0', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-00', 'unit_of_measurement': None, }) # --- @@ -76,7 +76,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-0.1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-01', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lcn/snapshots/test_sensor.ambr b/tests/components/lcn/snapshots/test_sensor.ambr index f8d57ed8904..1e172dda7e9 100644 --- a/tests/components/lcn/snapshots/test_sensor.ambr +++ b/tests/components/lcn/snapshots/test_sensor.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-led6', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-led6', 'unit_of_measurement': None, }) # --- @@ -76,7 +76,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-logicop1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-logicop1', 'unit_of_measurement': None, }) # --- @@ -123,7 +123,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-r1varsetpoint', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', 'unit_of_measurement': , }) # --- @@ -172,7 +172,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-var1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-var1', 'unit_of_measurement': , }) # --- diff --git a/tests/components/lcn/snapshots/test_switch.ambr b/tests/components/lcn/snapshots/test_switch.ambr index bc69b0ed483..7ba943a671f 100644 --- a/tests/components/lcn/snapshots/test_switch.ambr +++ b/tests/components/lcn/snapshots/test_switch.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-g000005-relay1', + 'unique_id': 'lcn/config_entry_pchk_json-g000005-relay1', 'unit_of_measurement': None, }) # --- @@ -76,7 +76,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-a1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-a1', 'unit_of_measurement': None, }) # --- @@ -123,7 +123,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-output1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-output1', 'unit_of_measurement': None, }) # --- @@ -170,7 +170,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-output2', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-output2', 'unit_of_measurement': None, }) # --- @@ -217,7 +217,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-r1varsetpoint', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', 'unit_of_measurement': None, }) # --- @@ -264,7 +264,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-relay1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-relay1', 'unit_of_measurement': None, }) # --- @@ -311,7 +311,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-relay2', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-relay2', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index ef3c2d3cb66..da967782539 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -138,15 +138,12 @@ async def test_async_entry_reload_on_host_event_received( async def test_migrate_1_1(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test migration config entry.""" entry_v1_1 = create_config_entry("pchk_v1_1", version=(1, 1)) - entry_v1_1.add_to_hass(hass) - - await hass.config_entries.async_setup(entry_v1_1.entry_id) - await hass.async_block_till_done() + await init_integration(hass, entry_v1_1) entry_migrated = hass.config_entries.async_get_entry(entry_v1_1.entry_id) assert entry_migrated.state is ConfigEntryState.LOADED - assert entry_migrated.version == 2 + assert entry_migrated.version == 3 assert entry_migrated.minor_version == 1 assert entry_migrated.data == snapshot @@ -155,14 +152,51 @@ async def test_migrate_1_1(hass: HomeAssistant, snapshot: SnapshotAssertion) -> async def test_migrate_1_2(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test migration config entry.""" entry_v1_2 = create_config_entry("pchk_v1_2", version=(1, 2)) - entry_v1_2.add_to_hass(hass) - - await hass.config_entries.async_setup(entry_v1_2.entry_id) - await hass.async_block_till_done() + await init_integration(hass, entry_v1_2) entry_migrated = hass.config_entries.async_get_entry(entry_v1_2.entry_id) assert entry_migrated.state is ConfigEntryState.LOADED - assert entry_migrated.version == 2 + assert entry_migrated.version == 3 assert entry_migrated.minor_version == 1 assert entry_migrated.data == snapshot + + +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) +async def test_migrate_2_1(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test migration config entry.""" + entry_v2_1 = create_config_entry("pchk_v2_1", version=(2, 1)) + await init_integration(hass, entry_v2_1) + + entry_migrated = hass.config_entries.async_get_entry(entry_v2_1.entry_id) + assert entry_migrated.state is ConfigEntryState.LOADED + assert entry_migrated.version == 3 + assert entry_migrated.minor_version == 1 + assert entry_migrated.data == snapshot + + +@pytest.mark.parametrize( + ("entity_id", "replace"), + [ + ("climate.climate1", ("-r1varsetpoint", "-var1.r1varsetpoint")), + ("scene.romantic", ("-00", "-0.0")), + ], +) +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) +async def test_entity_migration_on_2_1( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_id, replace +) -> None: + """Test entity.unique_id migration on config_entry migration from 2.1.""" + entry_v2_1 = create_config_entry("pchk_v2_1", version=(2, 1)) + await init_integration(hass, entry_v2_1) + + migrated_unique_id = entity_registry.async_get(entity_id).unique_id + old_unique_id = migrated_unique_id.replace(*replace) + entity_registry.async_update_entity(entity_id, new_unique_id=old_unique_id) + assert entity_registry.async_get(entity_id).unique_id == old_unique_id + + await hass.config_entries.async_unload(entry_v2_1.entry_id) + + entry_v2_1 = create_config_entry("pchk_v2_1", version=(2, 1)) + await init_integration(hass, entry_v2_1) + assert entity_registry.async_get(entity_id).unique_id == migrated_unique_id diff --git a/tests/components/lcn/test_websocket.py b/tests/components/lcn/test_websocket.py index 2c5fff89e19..02bf6b4c546 100644 --- a/tests/components/lcn/test_websocket.py +++ b/tests/components/lcn/test_websocket.py @@ -7,14 +7,13 @@ import pytest from homeassistant.components.lcn import AddressType from homeassistant.components.lcn.const import CONF_DOMAIN_DATA -from homeassistant.components.lcn.helpers import get_device_config, get_resource +from homeassistant.components.lcn.helpers import get_device_config from homeassistant.const import ( CONF_ADDRESS, CONF_DEVICES, CONF_DOMAIN, CONF_ENTITIES, CONF_NAME, - CONF_RESOURCE, CONF_TYPE, ) from homeassistant.core import HomeAssistant @@ -52,7 +51,7 @@ ENTITIES_DELETE_PAYLOAD = { "entry_id": "", CONF_ADDRESS: (0, 7, False), CONF_DOMAIN: "switch", - CONF_RESOURCE: "relay1", + CONF_DOMAIN_DATA: {"output": "RELAY1"}, } @@ -184,18 +183,14 @@ async def test_lcn_entities_add_command( for key in (CONF_ADDRESS, CONF_NAME, CONF_DOMAIN, CONF_DOMAIN_DATA) } - resource = get_resource( - ENTITIES_ADD_PAYLOAD[CONF_DOMAIN], ENTITIES_ADD_PAYLOAD[CONF_DOMAIN_DATA] - ).lower() - - assert {**entity_config, CONF_RESOURCE: resource} not in entry.data[CONF_ENTITIES] + assert entity_config not in entry.data[CONF_ENTITIES] await client.send_json_auto_id({**ENTITIES_ADD_PAYLOAD, "entry_id": entry.entry_id}) res = await client.receive_json() assert res["success"], res - assert {**entity_config, CONF_RESOURCE: resource} in entry.data[CONF_ENTITIES] + assert entity_config in entry.data[CONF_ENTITIES] async def test_lcn_entities_delete_command( @@ -213,7 +208,8 @@ async def test_lcn_entities_delete_command( for entity in entry.data[CONF_ENTITIES] if entity[CONF_ADDRESS] == ENTITIES_DELETE_PAYLOAD[CONF_ADDRESS] and entity[CONF_DOMAIN] == ENTITIES_DELETE_PAYLOAD[CONF_DOMAIN] - and entity[CONF_RESOURCE] == ENTITIES_DELETE_PAYLOAD[CONF_RESOURCE] + and entity[CONF_DOMAIN_DATA] + == ENTITIES_DELETE_PAYLOAD[CONF_DOMAIN_DATA] ] ) == 1 @@ -233,7 +229,8 @@ async def test_lcn_entities_delete_command( for entity in entry.data[CONF_ENTITIES] if entity[CONF_ADDRESS] == ENTITIES_DELETE_PAYLOAD[CONF_ADDRESS] and entity[CONF_DOMAIN] == ENTITIES_DELETE_PAYLOAD[CONF_DOMAIN] - and entity[CONF_RESOURCE] == ENTITIES_DELETE_PAYLOAD[CONF_RESOURCE] + and entity[CONF_DOMAIN_DATA] + == ENTITIES_DELETE_PAYLOAD[CONF_DOMAIN_DATA] ] ) == 0 diff --git a/tests/components/leaone/__init__.py b/tests/components/leaone/__init__.py index 3d62314fd9a..befc0a81028 100644 --- a/tests/components/leaone/__init__.py +++ b/tests/components/leaone/__init__.py @@ -1,8 +1,48 @@ """Tests for the Leaone integration.""" -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from uuid import UUID -SCALE_SERVICE_INFO = BluetoothServiceInfo( +from bleak.backends.device import BLEDevice +from bluetooth_data_tools import monotonic_time_coarse + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak + + +def make_bluetooth_service_info( + name: str, + manufacturer_data: dict[int, bytes], + service_uuids: list[str], + address: str, + rssi: int, + service_data: dict[UUID, bytes], + source: str, + tx_power: int = 0, + raw: bytes | None = None, +) -> BluetoothServiceInfoBleak: + """Create a BluetoothServiceInfoBleak object for testing.""" + return BluetoothServiceInfoBleak( + name=name, + manufacturer_data=manufacturer_data, + service_uuids=service_uuids, + address=address, + rssi=rssi, + service_data=service_data, + source=source, + device=BLEDevice( + name=name, + address=address, + details={}, + rssi=rssi, + ), + time=monotonic_time_coarse(), + advertisement=None, + connectable=True, + tx_power=tx_power, + raw=raw, + ) + + +SCALE_SERVICE_INFO = make_bluetooth_service_info( name="", address="5F:5A:5C:52:D3:94", rssi=-63, @@ -11,7 +51,7 @@ SCALE_SERVICE_INFO = BluetoothServiceInfo( service_data={}, source="local", ) -SCALE_SERVICE_INFO_2 = BluetoothServiceInfo( +SCALE_SERVICE_INFO_2 = make_bluetooth_service_info( name="", address="5F:5A:5C:52:D3:94", rssi=-63, @@ -23,7 +63,7 @@ SCALE_SERVICE_INFO_2 = BluetoothServiceInfo( service_data={}, source="local", ) -SCALE_SERVICE_INFO_3 = BluetoothServiceInfo( +SCALE_SERVICE_INFO_3 = make_bluetooth_service_info( name="", address="5F:5A:5C:52:D3:94", rssi=-63, diff --git a/tests/components/lg_thinq/conftest.py b/tests/components/lg_thinq/conftest.py index 05cb3164137..2eaddf1a83b 100644 --- a/tests/components/lg_thinq/conftest.py +++ b/tests/components/lg_thinq/conftest.py @@ -68,7 +68,7 @@ def mock_uuid() -> Generator[AsyncMock]: @pytest.fixture -def mock_thinq_api(mock_thinq_mqtt_client: AsyncMock) -> Generator[AsyncMock]: +def mock_config_thinq_api() -> Generator[AsyncMock]: """Mock a thinq api.""" with ( patch("homeassistant.components.lg_thinq.ThinQApi", autospec=True) as mock_api, @@ -77,6 +77,26 @@ def mock_thinq_api(mock_thinq_mqtt_client: AsyncMock) -> Generator[AsyncMock]: new=mock_api, ), ): + thinq_api = mock_api.return_value + thinq_api.async_get_device_list.return_value = ["air_conditioner"] + yield thinq_api + + +@pytest.fixture +def mock_invalid_thinq_api(mock_config_thinq_api: AsyncMock) -> AsyncMock: + """Mock an invalid thinq api.""" + mock_config_thinq_api.async_get_device_list = AsyncMock( + side_effect=ThinQAPIException( + code="1309", message="Not allowed api call", headers=None + ) + ) + return mock_config_thinq_api + + +@pytest.fixture +def mock_thinq_api(mock_thinq_mqtt_client: None) -> Generator[AsyncMock]: + """Mock a thinq api.""" + with patch("homeassistant.components.lg_thinq.ThinQApi", autospec=True) as mock_api: thinq_api = mock_api.return_value thinq_api.async_get_device_list.return_value = [ load_json_object_fixture("air_conditioner/device.json", DOMAIN) @@ -91,20 +111,11 @@ def mock_thinq_api(mock_thinq_mqtt_client: AsyncMock) -> Generator[AsyncMock]: @pytest.fixture -def mock_thinq_mqtt_client() -> Generator[AsyncMock]: - """Mock a thinq api.""" +def mock_thinq_mqtt_client() -> Generator[None]: + """Mock a thinq mqtt client.""" with patch( - "homeassistant.components.lg_thinq.mqtt.ThinQMQTTClient", autospec=True - ) as mock_api: - yield mock_api - - -@pytest.fixture -def mock_invalid_thinq_api(mock_thinq_api: AsyncMock) -> AsyncMock: - """Mock an invalid thinq api.""" - mock_thinq_api.async_get_device_list = AsyncMock( - side_effect=ThinQAPIException( - code="1309", message="Not allowed api call", headers=None - ) - ) - return mock_thinq_api + "homeassistant.components.lg_thinq.mqtt.ThinQMQTTClient", + autospec=True, + return_value=True, + ): + yield diff --git a/tests/components/lg_thinq/test_config_flow.py b/tests/components/lg_thinq/test_config_flow.py index 8c5afb4dac7..d1530ed29cd 100644 --- a/tests/components/lg_thinq/test_config_flow.py +++ b/tests/components/lg_thinq/test_config_flow.py @@ -15,7 +15,7 @@ from tests.common import MockConfigEntry async def test_config_flow( hass: HomeAssistant, - mock_thinq_api: AsyncMock, + mock_config_thinq_api: AsyncMock, mock_uuid: AsyncMock, mock_setup_entry: AsyncMock, ) -> None: @@ -37,11 +37,12 @@ async def test_config_flow( CONF_CONNECT_CLIENT_ID: MOCK_CONNECT_CLIENT_ID, } - mock_thinq_api.async_get_device_list.assert_called_once() + mock_config_thinq_api.async_get_device_list.assert_called_once() async def test_config_flow_invalid_pat( - hass: HomeAssistant, mock_invalid_thinq_api: AsyncMock + hass: HomeAssistant, + mock_invalid_thinq_api: AsyncMock, ) -> None: """Test that an thinq flow should be aborted with an invalid PAT.""" result = await hass.config_entries.flow.async_init( @@ -55,7 +56,9 @@ async def test_config_flow_invalid_pat( async def test_config_flow_already_configured( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_thinq_api: AsyncMock + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_config_thinq_api: AsyncMock, ) -> None: """Test that thinq flow should be aborted when already configured.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/lg_thinq/test_init.py b/tests/components/lg_thinq/test_init.py index 7da7e79fec0..d4c14e2e0c0 100644 --- a/tests/components/lg_thinq/test_init.py +++ b/tests/components/lg_thinq/test_init.py @@ -1,10 +1,14 @@ """Tests for the LG ThinQ integration.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch + +import pytest from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from . import setup_integration + from tests.common import MockConfigEntry @@ -14,9 +18,11 @@ async def test_load_unload_entry( mock_config_entry: MockConfigEntry, ) -> None: """Test load and unload entry.""" - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + with patch( + "homeassistant.components.lg_thinq.ThinQMQTT.async_connect", + return_value=True, + ): + await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -24,3 +30,20 @@ async def test_load_unload_entry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize("exception", [AttributeError(), TypeError(), ValueError()]) +async def test_config_not_ready( + hass: HomeAssistant, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, +) -> None: + """Test for setup failure exception occurred.""" + with patch( + "homeassistant.components.lg_thinq.ThinQMQTT.async_connect", + side_effect=exception, + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 29604ce7595..014e3ec8c35 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -958,21 +958,6 @@ async def test_light_brightness_step(hass: HomeAssistant) -> None: _, data = entity1.last_call("turn_on") assert data["brightness"] == 40 # 50 - 10 - await hass.services.async_call( - "light", - "turn_on", - { - "entity_id": [entity0.entity_id, entity1.entity_id], - "brightness_step_pct": 10, - }, - blocking=True, - ) - - _, data = entity0.last_call("turn_on") - assert data["brightness"] == 116 # 90 + (255 * 0.10) - _, data = entity1.last_call("turn_on") - assert data["brightness"] == 66 # 40 + (255 * 0.10) - await hass.services.async_call( "light", "turn_on", @@ -983,7 +968,49 @@ async def test_light_brightness_step(hass: HomeAssistant) -> None: blocking=True, ) - assert entity0.state == "off" # 126 - 126; brightness is 0, light should turn off + assert entity0.state == "off" # 40 - 126; brightness is 0, light should turn off + + +async def test_light_brightness_step_pct(hass: HomeAssistant) -> None: + """Test that percentage based brightness steps work as expected.""" + entity = MockLight("Test_0", STATE_ON) + + setup_test_component_platform(hass, light.DOMAIN, [entity]) + + 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 = 255 + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.attributes["brightness"] == 255 # 100% + + def reduce_brightness_by_ten_percent(): + return hass.services.async_call( + "light", + "turn_on", + { + "entity_id": [entity.entity_id], + "brightness_step_pct": -10, + }, + blocking=True, + ) + + await reduce_brightness_by_ten_percent() + _, data = entity.last_call("turn_on") + assert round(data["brightness"] / 2.55) == 90 # 100% - 10% = 90% + + await reduce_brightness_by_ten_percent() + _, data = entity.last_call("turn_on") + assert round(data["brightness"] / 2.55) == 80 # 90% - 10% = 80% + + await reduce_brightness_by_ten_percent() + _, data = entity.last_call("turn_on") + assert round(data["brightness"] / 2.55) == 70 # 80% - 10% = 70% @pytest.mark.usefixtures("enable_custom_integrations") diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index d22c4b2ec49..a6058c75bca 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from pylitterbot import Account, FeederRobot, LitterRobot3, LitterRobot4, Pet, Robot from pylitterbot.exceptions import InvalidCommandException +from pylitterbot.robot.litterrobot4 import HopperStatus import pytest from homeassistant.core import HomeAssistant @@ -84,6 +85,15 @@ def mock_account_with_litterrobot_4() -> MagicMock: return create_mock_account(v4=True) +@pytest.fixture +def mock_account_with_litterhopper() -> MagicMock: + """Mock account with LitterHopper attached to Litter-Robot 4.""" + return create_mock_account( + robot_data={"hopperStatus": HopperStatus.ENABLED, "isHopperRemoved": False}, + v4=True, + ) + + @pytest.fixture def mock_account_with_feederrobot() -> MagicMock: """Mock account with Feeder-Robot.""" diff --git a/tests/components/litterrobot/test_binary_sensor.py b/tests/components/litterrobot/test_binary_sensor.py index 3fe72aef7e3..a8da7e53d9f 100644 --- a/tests/components/litterrobot/test_binary_sensor.py +++ b/tests/components/litterrobot/test_binary_sensor.py @@ -30,3 +30,18 @@ async def test_binary_sensors( state = hass.states.get("binary_sensor.test_power_status") assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.PLUG assert state.state == "on" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_litterhopper_binary_sensors( + hass: HomeAssistant, + mock_account_with_litterhopper: MagicMock, +) -> None: + """Tests LitterHopper-specific binary sensors.""" + await setup_integration(hass, mock_account_with_litterhopper, BINARY_SENSOR_DOMAIN) + + state = hass.states.get("binary_sensor.test_hopper_connected") + assert state.state == "on" + assert ( + state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.CONNECTIVITY + ) diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index e290d96fcf4..bbc6274e56b 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -114,3 +114,12 @@ async def test_pet_weight_sensor( sensor = hass.states.get("sensor.kitty_weight") assert sensor.state == "9.1" assert sensor.attributes["unit_of_measurement"] == UnitOfMass.POUNDS + + +async def test_litterhopper_sensor( + hass: HomeAssistant, mock_account_with_litterhopper: MagicMock +) -> None: + """Tests LitterHopper sensors.""" + await setup_integration(hass, mock_account_with_litterhopper, PLATFORM_DOMAIN) + sensor = hass.states.get("sensor.test_hopper_status") + assert sensor.state == "enabled" diff --git a/tests/components/local_file/test_camera.py b/tests/components/local_file/test_camera.py index 0eb48aa3060..6b7e505fa26 100644 --- a/tests/components/local_file/test_camera.py +++ b/tests/components/local_file/test_camera.py @@ -13,11 +13,8 @@ from homeassistant.components.local_file.const import ( ) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ATTR_ENTITY_ID, CONF_FILE_PATH -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component -from homeassistant.util import slugify from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -212,76 +209,3 @@ async def test_update_file_path( service_data, blocking=True, ) - - -async def test_import_from_yaml_success( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: - """Test import.""" - - with ( - patch("os.path.isfile", Mock(return_value=True)), - patch("os.access", Mock(return_value=True)), - patch( - "homeassistant.components.local_file.camera.mimetypes.guess_type", - Mock(return_value=(None, None)), - ), - ): - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "local_file", - "file_path": "mock.file", - } - }, - ) - await hass.async_block_till_done() - - assert hass.config_entries.async_has_entries(DOMAIN) - state = hass.states.get("camera.config_test") - assert state.attributes.get("file_path") == "mock.file" - - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" - ) - assert issue - assert issue.translation_key == "deprecated_yaml" - - -async def test_import_from_yaml_fails( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: - """Test import fails due to not accessible file.""" - - with ( - patch("os.path.isfile", Mock(return_value=True)), - patch("os.access", Mock(return_value=False)), - patch( - "homeassistant.components.local_file.camera.mimetypes.guess_type", - Mock(return_value=(None, None)), - ), - ): - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "local_file", - "file_path": "mock.file", - } - }, - ) - await hass.async_block_till_done() - - assert not hass.config_entries.async_has_entries(DOMAIN) - assert not hass.states.get("camera.config_test") - - issue = issue_registry.async_get_issue( - DOMAIN, f"no_access_path_{slugify('mock.file')}" - ) - assert issue - assert issue.translation_key == "no_access_path" diff --git a/tests/components/local_file/test_config_flow.py b/tests/components/local_file/test_config_flow.py index dda9d606107..d828c947d0d 100644 --- a/tests/components/local_file/test_config_flow.py +++ b/tests/components/local_file/test_config_flow.py @@ -175,61 +175,3 @@ async def test_entry_already_exist( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - - -@pytest.mark.usefixtures("mock_setup_entry") -async def test_import(hass: HomeAssistant) -> None: - """Test import.""" - - with ( - patch("os.path.isfile", Mock(return_value=True)), - patch("os.access", Mock(return_value=True)), - patch( - "homeassistant.components.local_file.camera.mimetypes.guess_type", - Mock(return_value=(None, None)), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "name": DEFAULT_NAME, - "file_path": "mock/path.jpg", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["version"] == 1 - assert result["options"] == { - CONF_NAME: DEFAULT_NAME, - CONF_FILE_PATH: "mock/path.jpg", - } - - -@pytest.mark.usefixtures("mock_setup_entry") -async def test_import_already_exist( - hass: HomeAssistant, loaded_entry: MockConfigEntry -) -> None: - """Test import abort existing entry.""" - - with ( - patch("os.path.isfile", Mock(return_value=True)), - patch("os.access", Mock(return_value=True)), - patch( - "homeassistant.components.local_file.camera.mimetypes.guess_type", - Mock(return_value=(None, None)), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_NAME: DEFAULT_NAME, - CONF_FILE_PATH: "mock.file", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" diff --git a/tests/components/logger/test_init.py b/tests/components/logger/test_init.py index 24e58a77226..53b8b72b385 100644 --- a/tests/components/logger/test_init.py +++ b/tests/components/logger/test_init.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import async_call_logger_set_level, async_fire_time_changed HASS_NS = "unused.homeassistant" COMPONENTS_NS = f"{HASS_NS}.components" @@ -73,28 +73,27 @@ async def test_log_filtering( msg_test(filter_logger, True, "format string shouldfilter%s", "not") # Filtering should work even if log level is modified - await hass.services.async_call( - "logger", - "set_level", - {"test.filter": "warning"}, - blocking=True, - ) - assert filter_logger.getEffectiveLevel() == logging.WARNING - msg_test( - filter_logger, - False, - "this line containing shouldfilterall should still be filtered", - ) + async with async_call_logger_set_level( + "test.filter", "WARNING", hass=hass, caplog=caplog + ): + assert filter_logger.getEffectiveLevel() == logging.WARNING + msg_test( + filter_logger, + False, + "this line containing shouldfilterall should still be filtered", + ) - # Filtering should be scoped to a service - msg_test( - filter_logger, True, "this line containing otherfilterer should not be filtered" - ) - msg_test( - logging.getLogger("test.other_filter"), - False, - "this line containing otherfilterer SHOULD be filtered", - ) + # Filtering should be scoped to a service + msg_test( + filter_logger, + True, + "this line containing otherfilterer should not be filtered", + ) + msg_test( + logging.getLogger("test.other_filter"), + False, + "this line containing otherfilterer SHOULD be filtered", + ) async def test_setting_level(hass: HomeAssistant) -> None: diff --git a/tests/components/logger/test_websocket_api.py b/tests/components/logger/test_websocket_api.py index 5bc280535f9..debe26576bd 100644 --- a/tests/components/logger/test_websocket_api.py +++ b/tests/components/logger/test_websocket_api.py @@ -4,7 +4,7 @@ import logging from unittest.mock import patch from homeassistant import loader -from homeassistant.components.logger.helpers import async_get_domain_config +from homeassistant.components.logger.helpers import DATA_LOGGER from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -31,7 +31,6 @@ async def test_integration_log_info( assert msg["type"] == TYPE_RESULT assert msg["success"] assert {"domain": "http", "level": logging.DEBUG} in msg["result"] - assert {"domain": "websocket_api", "level": logging.DEBUG} in msg["result"] async def test_integration_log_level_logger_not_loaded( @@ -77,7 +76,7 @@ async def test_integration_log_level( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.DEBUG } @@ -127,7 +126,7 @@ async def test_custom_integration_log_level( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.hue": logging.DEBUG, "custom_components.hue": logging.DEBUG, "some_other_logger": logging.DEBUG, @@ -183,7 +182,7 @@ async def test_module_log_level( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.DEBUG, "homeassistant.components.other_component": logging.WARNING, } @@ -200,7 +199,7 @@ async def test_module_log_level_override( {"logger": {"logs": {"homeassistant.components.websocket_api": "warning"}}}, ) - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.WARNING } @@ -219,7 +218,7 @@ async def test_module_log_level_override( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.ERROR } @@ -238,7 +237,7 @@ async def test_module_log_level_override( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.DEBUG } @@ -257,6 +256,6 @@ async def test_module_log_level_override( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.NOTSET } diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index d7429f6087d..e180b9e9363 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -104,7 +104,9 @@ async def integration_fixture( "pressure_sensor", "room_airconditioner", "silabs_dishwasher", + "silabs_evse_charging", "silabs_laundrywasher", + "silabs_water_heater", "smoke_detector", "switch_unit", "temperature_sensor", diff --git a/tests/components/matter/fixtures/nodes/silabs_evse_charging.json b/tests/components/matter/fixtures/nodes/silabs_evse_charging.json new file mode 100644 index 00000000000..3188ba81ad6 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/silabs_evse_charging.json @@ -0,0 +1,580 @@ +{ + "node_id": 23, + "date_commissioned": "2024-12-17T18:14:53.210190", + "last_interview": "2024-12-17T18:14:53.211611", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 43, 44, 45, 48, 49, 51, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 18, + "0/40/1": "Silabs", + "0/40/2": 65521, + "0/40/3": "evse", + "0/40/4": 32769, + "0/40/5": "evse", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/15": "TEST_SN", + "0/40/18": "evse", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17039360, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 4, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 21, 22, 65528, 65529, 65531, + 65532, 65533 + ], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/45/65532": 0, + "0/45/65533": 1, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5zMzM=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/8": [0], + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "AkI9NTnB", + "5": ["rBEAAQ=="], + "6": [""], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwp/F0T", + "5": ["wKgBpw=="], + "6": [ + "KgEOCgKzOZCNB+q+Uz0I9w==", + "KgEOCgKzOZC/O1Ew1WvS4A==", + "/oAAAAAAAADml3Ozl7GZug==" + ], + "7": 2 + }, + { + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 + } + ], + "0/51/1": 1, + "0/51/2": 10129, + "0/51/8": true, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 2, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRFxgkBwEkCAEwCUEECp4PASYUFk/DwQqGNBikYdiBRDJZbrfF4AYK8Y9jOeIpx7Xy+giJhmTpAVZ662hwszsFDGULGY/owXtMrqTxEDcKNQEoARgkAgE2AwQCBAEYMAQUqBmxO16fPQhbf33Gb2XwQ+NkXpswBRTx8+4bdkuqlxInfB5LXkhRBBvS2hgwC0A8aefsLm663Vuy+TkSvn/oLhRqt2phrG+i5aM5o15xiWDjnNVdUYpT09+K0mgVoMdFuFsmoWQxQh6jahaFJzUgGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEGp55xGRB0FBQ3Yw7ayQSzVtYA0BtCJFm9vRRcdr+nk0cuGX6zrUowSYOO/qiRBEACcCNNSqKh+DpRm2uVLOtaDcKNQEpARgkAmAwBBTx8+4bdkuqlxInfB5LXkhRBBvS2jAFFIxTG68U5WQVsk8AtvSQyeK3KLqPGDALQIw/6q5ILMNdOMcSif8HNbEgpjBeaBMfUpzOJFCRPM16sv1xiq3mALZj0u+iG8lUJEvDJOFKPoBvsOubwIwRgAQY", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BMeyHMXjJpVWF9saehBu7pZLTwdopKZTl5JdhU0/ozZ/sk1paVFE1U8OtuZqM/S/4W/fnkCnUrQ/Xcs7Ddy0hPE=", + "2": 65521, + "3": 1, + "4": 23, + "5": "HA_test", + "254": 1 + }, + { + "1": "BBF47gm4BEBA6LXQluAHjn6P3+MZKrhuMcJligg1xcBM7X++F7GsZFh4hYAhdmD9HHwhtZxH2c85aAzbpikViwI=", + "2": 65521, + "3": 1, + "4": 100, + "5": "", + "254": 2 + } + ], + "0/62/2": 16, + "0/62/3": 2, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEx7IcxeMmlVYX2xp6EG7ulktPB2ikplOXkl2FTT+jNn+yTWlpUUTVTw625moz9L/hb9+eQKdStD9dyzsN3LSE8TcKNQEpARgkAmAwBBSMUxuvFOVkFbJPALb0kMnityi6jzAFFIxTG68U5WQVsk8AtvSQyeK3KLqPGDALQPBVUg+OBUWl1pe/k55ZigAZl3lfBP1Qd5zQP4AUB45mNTzdli8DRCj+h7cIs3JHQQPlUaRvG5xUoBZ+C7Gg2sQY", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEEXjuCbgEQEDotdCW4AeOfo/f4xkquG4xwmWKCDXFwEztf74XsaxkWHiFgCF2YP0cfCG1nEfZzzloDNumKRWLAjcKNQEpARgkAmAwBBQD3rx0jOdkiCPt06hxW7Z2jJBPXTAFFAPevHSM52SII+3TqHFbtnaMkE9dGDALQL+L3Zc6En6Ionk6WIz+lM50iwOEzTi9VwyYQRUdtO99T8jRX52+Olh6zcUtWQuYO2XYiH2OZ8lM4guqqnS8U4UY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 5, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 1293, + "1": 1 + }, + { + "0": 1292, + "1": 1 + }, + { + "0": 1296, + "1": 1 + }, + { + "0": 17, + "1": 1 + } + ], + "1/29/1": [3, 29, 47, 144, 145, 152, 153, 156, 157, 159], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/47/0": 1, + "1/47/1": 0, + "1/47/2": "Primary Mains Power", + "1/47/5": 0, + "1/47/7": 230000, + "1/47/8": 32000, + "1/47/31": [1], + "1/47/65532": 1, + "1/47/65533": 3, + "1/47/65528": [], + "1/47/65529": [], + "1/47/65531": [0, 1, 2, 5, 7, 8, 31, 65528, 65529, 65531, 65532, 65533], + "1/144/0": 2, + "1/144/1": 3, + "1/144/2": [ + { + "0": 5, + "1": true, + "2": -50000000, + "3": 50000000, + "4": [ + { + "0": -50000000, + "1": -10000000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -9999999, + "1": 9999999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 10000000, + "1": 50000000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 2, + "1": true, + "2": -100000, + "3": 100000, + "4": [ + { + "0": -100000, + "1": -5000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -4999, + "1": 4999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 5000, + "1": 100000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 1, + "1": true, + "2": -500000, + "3": 500000, + "4": [ + { + "0": -500000, + "1": -100000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -99999, + "1": 99999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 100000, + "1": 500000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + } + ], + "1/144/3": [], + "1/144/4": null, + "1/144/5": null, + "1/144/6": null, + "1/144/7": null, + "1/144/8": null, + "1/144/9": null, + "1/144/10": null, + "1/144/11": null, + "1/144/12": null, + "1/144/13": null, + "1/144/14": null, + "1/144/15": [ + { + "0": 1, + "1": 100000 + } + ], + "1/144/16": [ + { + "0": 1, + "1": 100000 + } + ], + "1/144/17": null, + "1/144/18": null, + "1/144/65532": 31, + "1/144/65533": 1, + "1/144/65528": [], + "1/144/65529": [], + "1/144/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 65528, + 65529, 65531, 65532, 65533 + ], + "1/145/0": { + "0": 14, + "1": true, + "2": 0, + "3": 1000000000000000, + "4": [ + { + "0": 98440650424323, + "1": 98442759724168, + "2": 0, + "3": 0, + "5": 140728898420739, + "6": 98440650424355 + } + ] + }, + "1/145/1": null, + "1/145/2": null, + "1/145/3": null, + "1/145/4": null, + "1/145/5": { + "0": 0, + "1": 0, + "2": 0, + "3": 0 + }, + "1/145/65532": 15, + "1/145/65533": 1, + "1/145/65528": [], + "1/145/65529": [], + "1/145/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "1/152/0": 0, + "1/152/1": false, + "1/152/2": 1, + "1/152/3": 1200000, + "1/152/4": 7600000, + "1/152/5": null, + "1/152/6": null, + "1/152/7": 0, + "1/152/65532": 123, + "1/152/65533": 4, + "1/152/65528": [], + "1/152/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/152/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "1/153/0": 3, + "1/153/1": 1, + "1/153/2": 0, + "1/153/3": null, + "1/153/5": 32000, + "1/153/6": 2000, + "1/153/7": 30000, + "1/153/9": 32000, + "1/153/10": 600, + "1/153/35": null, + "1/153/36": null, + "1/153/37": null, + "1/153/38": null, + "1/153/39": null, + "1/153/64": 2, + "1/153/65": 0, + "1/153/66": 0, + "1/153/65532": 9, + "1/153/65533": 3, + "1/153/65528": [0], + "1/153/65529": [1, 2, 5, 6, 7, 4], + "1/153/65531": [ + 0, 1, 2, 3, 5, 6, 7, 9, 10, 35, 36, 37, 38, 39, 64, 65, 66, 65528, 65529, + 65531, 65532, 65533 + ], + "1/156/65532": 1, + "1/156/65533": 1, + "1/156/65528": [], + "1/156/65529": [], + "1/156/65531": [65528, 65529, 65531, 65532, 65533], + "1/157/0": [ + { + "0": "Manual", + "1": 0, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Auto-scheduled", + "1": 1, + "2": [ + { + "1": 16385 + } + ] + }, + { + "0": "Solar", + "1": 2, + "2": [ + { + "1": 16386 + } + ] + }, + { + "0": "Auto-scheduled with Solar charging", + "1": 3, + "2": [ + { + "1": 16385 + }, + { + "1": 16386 + } + ] + } + ], + "1/157/1": 1, + "1/157/65532": 0, + "1/157/65533": 2, + "1/157/65528": [1], + "1/157/65529": [0], + "1/157/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/159/0": [ + { + "0": "No energy management (forecast only)", + "1": 0, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Device optimizes (no local or grid control)", + "1": 1, + "2": [ + { + "1": 16385 + } + ] + }, + { + "0": "Optimized within building", + "1": 2, + "2": [ + { + "1": 16386 + }, + { + "1": 16385 + } + ] + }, + { + "0": "Optimized for grid", + "1": 3, + "2": [ + { + "1": 16385 + }, + { + "1": 16387 + } + ] + }, + { + "0": "Optimized for grid and building", + "1": 4, + "2": [ + { + "1": 16386 + }, + { + "1": 16385 + }, + { + "1": 16387 + } + ] + } + ], + "1/159/1": 3, + "1/159/65532": 0, + "1/159/65533": 2, + "1/159/65528": [1], + "1/159/65529": [0], + "1/159/65531": [0, 1, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/silabs_water_heater.json b/tests/components/matter/fixtures/nodes/silabs_water_heater.json new file mode 100644 index 00000000000..7b764f3b3f1 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/silabs_water_heater.json @@ -0,0 +1,534 @@ +{ + "node_id": 25, + "date_commissioned": "2024-11-21T20:21:44.371473", + "last_interview": "2024-11-21T20:21:44.371503", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 43, 44, 45, 48, 49, 51, 60, 62, 63], + "0/29/2": [], + "0/29/3": [2], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 18, + "0/40/1": "Silabs", + "0/40/2": 65521, + "0/40/3": "Water Heater", + "0/40/4": 32773, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "v1.3-fix-energy-man-app-comp-2d92654525-dirty", + "0/40/15": "", + "0/40/18": "1868F000380F300B", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 17039360, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 4, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 21, 22, 65528, 65529, 65531, + 65532, 65533 + ], + "0/43/0": "", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/45/65532": 0, + "0/45/65533": 1, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "p0jbsOzJRNw=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "p0jbsOzJRNw=", + "0/49/7": null, + "0/49/8": [0], + "0/49/65532": 2, + "0/49/65533": 2, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [0, 1, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "MyHome", + "1": true, + "2": null, + "3": null, + "4": "0ln4A+M/qdU=", + "5": [], + "6": [ + "/QANuACgAAAAAAD//gCEAA==", + "/akBUIsgAADu+RflBK+awg==", + "/QANuACgAACOGElK6AMfiw==", + "/oAAAAAAAADQWfgD4z+p1Q==" + ], + "7": 4 + } + ], + "0/51/1": 2, + "0/51/2": 970, + "0/51/8": true, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 2, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRLBgkBwEkCAEwCUEET/Kg7i1M+NQnTtjldQKCfg81STfZkuBWKlnUUolYjkKNUkOEGf/CAMckg3BH/vbbS8wbC17pWG8EvB7D6RSUfDcKNQEoARgkAgE2AwQCBAEYMAQUBAW4lb/V1fEJebN5Z4UTmE5XrEowBRRv4WHQKIysaFy3b/zkFJmrjWlt7hgwC0Cl0ZjooRQMxjnO0liVKSiIwY+sl0S34aMXNR/PAU89ZqTlHJocegee54S4ajdVZsj1LMV6YWQA3GNw61sC79aFGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEERIK+dKrh7jNjamMZKV9Ir5gyKBMyce881JnXvjjdrJI3B3OjB6DbhqXvpgk96gZam85WxwGWrRlJEjVl2YQu6DcKNQEpARgkAmAwBBRv4WHQKIysaFy3b/zkFJmrjWlt7jAFFLsR9bzzqpjG9Z5aOkD8b8KMO7AQGDALQAK1q01Umn5ER39/84eai6HfZDKTNsGsuLyhIfpQa6XZQXenGbFDeenDLy8zv5NOLtwu8b44Zv0IrqONItfZqOMY", + "254": 3 + } + ], + "0/62/1": [ + { + "1": "BNI+NL43G+mbJrQUfyNKwd2SHwAPJT3lgk8Ru5z0mzaXqXtfF8C4nYRSBypr7WVg2dx5dzDPTQQfiwGZQhav3nY=", + "2": 4939, + "3": 2, + "4": 44, + "5": "HA_test", + "254": 3 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/4": [ + "FTABAQAkAgE3AyYUyakYCSYVj6gLsxgmBP2G+CskBQA3BiYUyakYCSYVj6gLsxgkBwEkCAEwCUEEgYwxrTB+tyiEGfrRwjlXTG34MiQtJXbg5Qqd0ohdRW7MfwYY7vZiX/0h9hI8MqUralFaVPcnghAP0MSJm1YrqTcKNQEpARgkAmAwBBS3BS9aJzt+p6i28Nj+trB2Uu+vdzAFFLcFL1onO36nqLbw2P62sHZS7693GDALQIrLt7Uq3S9HEe7apdzYSR+j3BLWNXSTLWD4YbrdyYLpm6xqHDV/NPARcIp4skZdtz91WwFBDfuS4jO5aVoER1sY", + "FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEE0j40vjcb6ZsmtBR/I0rB3ZIfAA8lPeWCTxG7nPSbNpepe18XwLidhFIHKmvtZWDZ3Hl3MM9NBB+LAZlCFq/edjcKNQEpARgkAmAwBBS7EfW886qYxvWeWjpA/G/CjDuwEDAFFLsR9bzzqpjG9Z5aOkD8b8KMO7AQGDALQIgQgt5asUGXO0ZyTWWKdjAmBSoJAzRMuD4Z+tQYZanQ3s0OItL07MU2In6uyXhjNBfjJlRqon780lhjTsm2Y+8Y" + ], + "0/62/5": 3, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/3/0": 0, + "2/3/1": 0, + "2/3/65532": 0, + "2/3/65533": 5, + "2/3/65528": [], + "2/3/65529": [0], + "2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "2/29/0": [ + { + "0": 1293, + "1": 1 + }, + { + "0": 1296, + "1": 1 + }, + { + "0": 1295, + "1": 1 + } + ], + "2/29/1": [3, 29, 144, 145, 148, 152, 156, 158, 159], + "2/29/2": [], + "2/29/3": [], + "2/29/65532": 0, + "2/29/65533": 2, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/144/0": 0, + "2/144/1": 3, + "2/144/2": [ + { + "0": 5, + "1": true, + "2": -50000000, + "3": 50000000, + "4": [ + { + "0": -50000000, + "1": -10000000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -9999999, + "1": 9999999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 10000000, + "1": 50000000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 2, + "1": true, + "2": -100000, + "3": 100000, + "4": [ + { + "0": -100000, + "1": -5000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -4999, + "1": 4999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 5000, + "1": 100000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 1, + "1": true, + "2": -500000, + "3": 500000, + "4": [ + { + "0": -500000, + "1": -100000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -99999, + "1": 99999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 100000, + "1": 500000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + } + ], + "2/144/3": [], + "2/144/4": 230000, + "2/144/5": 100, + "2/144/6": null, + "2/144/7": null, + "2/144/8": 23000, + "2/144/9": null, + "2/144/10": null, + "2/144/11": null, + "2/144/12": null, + "2/144/13": null, + "2/144/14": 50, + "2/144/15": [ + { + "0": 1, + "1": 100000 + } + ], + "2/144/16": [ + { + "0": 1, + "1": 100000 + } + ], + "2/144/17": null, + "2/144/18": null, + "2/144/65532": 31, + "2/144/65533": 1, + "2/144/65528": [], + "2/144/65529": [], + "2/144/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 65528, + 65529, 65531, 65532, 65533 + ], + "2/145/0": { + "0": 14, + "1": true, + "2": 0, + "3": 1000000000000000, + "4": [ + { + "0": 0, + "1": 0 + } + ] + }, + "2/145/1": null, + "2/145/2": null, + "2/145/3": null, + "2/145/4": null, + "2/145/5": { + "0": 0, + "1": 0, + "2": 0, + "3": 0 + }, + "2/145/65532": 15, + "2/145/65533": 1, + "2/145/65528": [], + "2/145/65529": [], + "2/145/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "2/148/0": 1, + "2/148/1": 0, + "2/148/2": 200, + "2/148/3": 4000000, + "2/148/4": 40, + "2/148/5": 0, + "2/148/65532": 3, + "2/148/65533": 2, + "2/148/65528": [], + "2/148/65529": [0, 1], + "2/148/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "2/152/0": 2, + "2/152/1": false, + "2/152/2": 1, + "2/152/3": 1200000, + "2/152/4": 7600000, + "2/152/5": null, + "2/152/6": null, + "2/152/7": 0, + "2/152/65532": 123, + "2/152/65533": 4, + "2/152/65528": [], + "2/152/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "2/152/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "2/156/65532": 1, + "2/156/65533": 1, + "2/156/65528": [], + "2/156/65529": [], + "2/156/65531": [65528, 65529, 65531, 65532, 65533], + "2/158/0": [ + { + "0": "Off", + "1": 0, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Manual", + "1": 1, + "2": [ + { + "1": 16385 + } + ] + }, + { + "0": "Timed", + "1": 2, + "2": [ + { + "1": 16386 + } + ] + } + ], + "2/158/1": 1, + "2/158/65532": 0, + "2/158/65533": 1, + "2/158/65528": [1], + "2/158/65529": [0], + "2/158/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "2/159/0": [ + { + "0": "No energy management (forecast only)", + "1": 0, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Device optimizes (no local or grid control)", + "1": 1, + "2": [ + { + "1": 16385 + } + ] + }, + { + "0": "Optimized within building", + "1": 2, + "2": [ + { + "1": 16386 + }, + { + "1": 16385 + } + ] + }, + { + "0": "Optimized for grid", + "1": 3, + "2": [ + { + "1": 16385 + }, + { + "1": 16387 + } + ] + }, + { + "0": "Optimized for grid and building", + "1": 4, + "2": [ + { + "1": 16386 + }, + { + "1": 16385 + }, + { + "1": 16387 + } + ] + } + ], + "2/159/1": 0, + "2/159/65532": 0, + "2/159/65533": 2, + "2/159/65528": [1], + "2/159/65529": [0], + "2/159/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "2/513/0": 5000, + "2/513/3": 4000, + "2/513/4": 6500, + "2/513/18": 6500, + "2/513/21": 4000, + "2/513/22": 6500, + "2/513/27": 2, + "2/513/28": 4, + "2/513/65532": 1, + "2/513/65533": 7, + "2/513/65528": [], + "2/513/65529": [0], + "2/513/65531": [0, 27, 28, 65528, 65529, 65531, 65532, 65533] + }, + "2/513/0": 5000, + "2/513/3": 4000, + "2/513/4": 6500, + "2/513/18": 6500, + "2/513/21": 4000, + "2/513/22": 6500, + "2/513/27": 2, + "2/513/28": 4, + "2/513/65532": 1, + "2/513/65533": 7, + "2/513/65528": [], + "2/513/65529": [0], + "2/513/65531": [0, 27, 28, 65528, 65529, 65531, 65532, 65533], + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index c8de905d03f..feca62ffa31 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -383,6 +383,197 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.evse_charging_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': 'Charging status', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_charging_status', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseChargingStatusSensor-153-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'evse Charging status', + }), + 'context': , + 'entity_id': 'binary_sensor.evse_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.evse_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_plug_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvsePlugStateSensor-153-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'evse Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.evse_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_supply_charging_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.evse_supply_charging_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': 'Supply charging state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_supply_charging_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseSupplyStateSensor-153-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_supply_charging_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'evse Supply charging state', + }), + 'context': , + 'entity_id': 'binary_sensor.evse_supply_charging_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[silabs_water_heater][binary_sensor.water_heater_boost_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.water_heater_boost_state', + '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': 'Boost state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'boost_state', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-WaterHeaterManagementBoostStateSensor-148-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_water_heater][binary_sensor.water_heater_boost_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Heater Boost state', + }), + 'context': , + 'entity_id': 'binary_sensor.water_heater_boost_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[smoke_detector][binary_sensor.smoke_sensor_battery_alert-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index d777b9d48d0..e1ee782cd3b 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -483,7 +483,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Altitude above Sea Level', + 'original_name': 'Altitude above sea level', 'platform': 'matter', 'previous_unique_id': None, 'supported_features': 0, @@ -496,7 +496,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', - 'friendly_name': 'Eve Weather Altitude above Sea Level', + 'friendly_name': 'Eve Weather Altitude above sea level', 'max': 9000, 'min': 0, 'mode': , diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 772ee297e13..5222dda1ab5 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -1543,6 +1543,128 @@ 'state': 'previous', }) # --- +# name: test_selects[silabs_evse_charging][select.evse_energy_management_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'No energy management (forecast only)', + 'Device optimizes (no local or grid control)', + 'Optimized within building', + 'Optimized for grid', + 'Optimized for grid and building', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.evse_energy_management_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': 'Energy management mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_energy_management_mode', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-MatterDeviceEnergyManagementMode-159-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_evse_charging][select.evse_energy_management_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'evse Energy management mode', + 'options': list([ + 'No energy management (forecast only)', + 'Device optimizes (no local or grid control)', + 'Optimized within building', + 'Optimized for grid', + 'Optimized for grid and building', + ]), + }), + 'context': , + 'entity_id': 'select.evse_energy_management_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Optimized for grid', + }) +# --- +# name: test_selects[silabs_evse_charging][select.evse_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Manual', + 'Auto-scheduled', + 'Solar', + 'Auto-scheduled with Solar charging', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.evse_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': 'Mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mode', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-MatterEnergyEvseMode-157-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_evse_charging][select.evse_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'evse Mode', + 'options': list([ + 'Manual', + 'Auto-scheduled', + 'Solar', + 'Auto-scheduled with Solar charging', + ]), + }), + 'context': , + 'entity_id': 'select.evse_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Auto-scheduled', + }) +# --- # name: test_selects[silabs_laundrywasher][select.laundrywasher_number_of_rinses-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1717,6 +1839,68 @@ 'state': 'Colors', }) # --- +# name: test_selects[silabs_water_heater][select.water_heater_energy_management_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'No energy management (forecast only)', + 'Device optimizes (no local or grid control)', + 'Optimized within building', + 'Optimized for grid', + 'Optimized for grid and building', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.water_heater_energy_management_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': 'Energy management mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_energy_management_mode', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-MatterDeviceEnergyManagementMode-159-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_water_heater][select.water_heater_energy_management_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Heater Energy management mode', + 'options': list([ + 'No energy management (forecast only)', + 'Device optimizes (no local or grid control)', + 'Optimized within building', + 'Optimized for grid', + 'Optimized for grid and building', + ]), + }), + 'context': , + 'entity_id': 'select.water_heater_energy_management_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'No energy management (forecast only)', + }) +# --- # name: test_selects[switch_unit][select.mock_switchunit_power_on_behavior_on_startup-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index cb26f1d8e70..2c6ef8ad51b 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -2866,6 +2866,323 @@ 'state': '120.0', }) # --- +# name: test_sensors[silabs_evse_charging][sensor.evse_circuit_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_circuit_capacity', + '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': 'Circuit capacity', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_circuit_capacity', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseCircuitCapacity-153-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_circuit_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse Circuit capacity', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_circuit_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32.0', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_fault_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'meter_failure', + 'over_voltage', + 'under_voltage', + 'over_current', + 'contact_wet_failure', + 'contact_dry_failure', + 'power_loss', + 'power_quality', + 'pilot_short_circuit', + 'emergency_stop', + 'ev_disconnected', + 'wrong_power_supply', + 'live_neutral_swap', + 'over_temperature', + 'other', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_fault_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': 'Fault state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_fault_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseFaultState-153-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_fault_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'evse Fault state', + 'options': list([ + 'no_error', + 'meter_failure', + 'over_voltage', + 'under_voltage', + 'over_current', + 'contact_wet_failure', + 'contact_dry_failure', + 'power_loss', + 'power_quality', + 'pilot_short_circuit', + 'emergency_stop', + 'ev_disconnected', + 'wrong_power_supply', + 'live_neutral_swap', + 'over_temperature', + 'other', + ]), + }), + 'context': , + 'entity_id': 'sensor.evse_fault_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_max_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_max_charge_current', + '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': 'Max charge current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_max_charge_current', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseMaximumChargeCurrent-153-7', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_max_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse Max charge current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_max_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_min_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_min_charge_current', + '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': 'Min charge current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_min_charge_current', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseMinimumChargeCurrent-153-6', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_min_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse Min charge current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_min_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_user_max_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_user_max_charge_current', + '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': 'User max charge current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_user_max_charge_current', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseUserMaximumChargeCurrent-153-9', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_user_max_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse User max charge current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_user_max_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32.0', + }) +# --- # name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3218,6 +3535,341 @@ 'state': '120.0', }) # --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.water_heater_current', + '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': 'Current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Water Heater Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_heater_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_hot_water_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_heater_hot_water_level', + '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': 'Hot water level', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tank_percentage', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-WaterHeaterManagementTankPercentage-148-4', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_hot_water_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Heater Hot water level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.water_heater_hot_water_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.water_heater_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': 'Power', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementWatt-144-8', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Water Heater Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_heater_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.0', + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_required_heating_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.water_heater_required_heating_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Required heating energy', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'estimated_heat_required', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-WaterHeaterManagementEstimatedHeatRequired-148-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_required_heating_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Water Heater Required heating energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_heater_required_heating_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_tank_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_heater_tank_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': 'Tank volume', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tank_volume', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-WaterHeaterManagementTankVolume-148-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_tank_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Water Heater Tank volume', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_heater_tank_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200', + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.water_heater_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Water Heater Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_heater_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- # name: test_sensors[smoke_detector][sensor.smoke_sensor_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index ebf43117846..d60a2933e6f 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -334,6 +334,53 @@ 'state': 'off', }) # --- +# name: test_switches[silabs_evse_charging][switch.evse_enable_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.evse_enable_charging', + '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': 'Enable charging', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_charging_switch', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseChargingSwitch-153-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[silabs_evse_charging][switch.evse_enable_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'evse Enable charging', + }), + 'context': , + 'entity_id': 'switch.evse_enable_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches[switch_unit][switch.mock_switchunit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_water_heater.ambr b/tests/components/matter/snapshots/test_water_heater.ambr new file mode 100644 index 00000000000..fcf9a7665fd --- /dev/null +++ b/tests/components/matter/snapshots/test_water_heater.ambr @@ -0,0 +1,69 @@ +# serializer version: 1 +# name: test_water_heaters[silabs_water_heater][water_heater.water_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_temp': 65, + 'min_temp': 40, + 'operation_list': list([ + 'eco', + 'high_demand', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.water_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-MatterWaterHeater-513-18', + 'unit_of_measurement': None, + }) +# --- +# name: test_water_heaters[silabs_water_heater][water_heater.water_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 50, + 'friendly_name': 'Water Heater', + 'max_temp': 65, + 'min_temp': 40, + 'operation_list': list([ + 'eco', + 'high_demand', + 'off', + ]), + 'operation_mode': 'eco', + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 65, + }), + 'context': , + 'entity_id': 'water_heater.water_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'eco', + }) +# --- diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index cddee975ac8..c20c5cb7f29 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -147,3 +147,73 @@ async def test_optional_sensor_from_featuremap( ) state = hass.states.get(entity_id) assert state is None + + +@pytest.mark.parametrize("node_fixture", ["silabs_evse_charging"]) +async def test_evse_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test evse sensors.""" + # Test StateEnum value with binary_sensor.evse_charging_status + entity_id = "binary_sensor.evse_charging_status" + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + # switch to PluggedInDemand state + set_node_attribute(matter_node, 1, 153, 0, 2) + await trigger_subscription_callback( + hass, matter_client, data=(matter_node.node_id, "1/153/0", 2) + ) + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + + # Test StateEnum value with binary_sensor.evse_plug + entity_id = "binary_sensor.evse_plug" + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + # switch to NotPluggedIn state + set_node_attribute(matter_node, 1, 153, 0, 0) + await trigger_subscription_callback( + hass, matter_client, data=(matter_node.node_id, "1/153/0", 0) + ) + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + + # Test SupplyStateEnum value with binary_sensor.evse_supply_charging + entity_id = "binary_sensor.evse_supply_charging_state" + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + # switch to Disabled state + set_node_attribute(matter_node, 1, 153, 1, 0) + await trigger_subscription_callback( + hass, matter_client, data=(matter_node.node_id, "1/153/1", 0) + ) + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + + +@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) +async def test_water_heater( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test water heater sensor.""" + # BoostState + state = hass.states.get("binary_sensor.water_heater_boost_state") + assert state + assert state.state == "off" + + set_node_attribute(matter_node, 2, 148, 5, 1) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("binary_sensor.water_heater_boost_state") + assert state + assert state.state == "on" diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 251aab73e3b..03ffa31125e 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -399,3 +399,115 @@ async def test_list_sensor( state = hass.states.get("sensor.laundrywasher_current_phase") assert state assert state.state == "rinse" + + +@pytest.mark.parametrize("node_fixture", ["silabs_evse_charging"]) +async def test_evse_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test evse sensors.""" + # EnergyEvseFaultState + state = hass.states.get("sensor.evse_fault_state") + assert state + assert state.state == "no_error" + + set_node_attribute(matter_node, 1, 153, 2, 4) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_fault_state") + assert state + assert state.state == "over_current" + + # EnergyEvseCircuitCapacity + state = hass.states.get("sensor.evse_circuit_capacity") + assert state + assert state.state == "32.0" + + set_node_attribute(matter_node, 1, 153, 5, 63000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_circuit_capacity") + assert state + assert state.state == "63.0" + + # EnergyEvseMinimumChargeCurrent + state = hass.states.get("sensor.evse_min_charge_current") + assert state + assert state.state == "2.0" + + set_node_attribute(matter_node, 1, 153, 6, 5000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_min_charge_current") + assert state + assert state.state == "5.0" + + # EnergyEvseMaximumChargeCurrent + state = hass.states.get("sensor.evse_max_charge_current") + assert state + assert state.state == "30.0" + + set_node_attribute(matter_node, 1, 153, 7, 20000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_max_charge_current") + assert state + assert state.state == "20.0" + + # EnergyEvseUserMaximumChargeCurrent + state = hass.states.get("sensor.evse_user_max_charge_current") + assert state + assert state.state == "32.0" + + set_node_attribute(matter_node, 1, 153, 9, 63000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_user_max_charge_current") + assert state + assert state.state == "63.0" + + +@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) +async def test_water_heater( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test water heater sensor.""" + # TankVolume + state = hass.states.get("sensor.water_heater_tank_volume") + assert state + assert state.state == "200" + + set_node_attribute(matter_node, 2, 148, 2, 100) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.water_heater_tank_volume") + assert state + assert state.state == "100" + + # EstimatedHeatRequired + state = hass.states.get("sensor.water_heater_required_heating_energy") + assert state + assert state.state == "4.0" + + set_node_attribute(matter_node, 2, 148, 3, 1000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.water_heater_required_heating_energy") + assert state + assert state.state == "1.0" + + # TankPercentage + state = hass.states.get("sensor.water_heater_hot_water_level") + assert state + assert state.state == "40" + + set_node_attribute(matter_node, 2, 148, 4, 50) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.water_heater_hot_water_level") + assert state + assert state.state == "50" diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index e82848fcc3a..f294cd31a26 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters +from chip.clusters.Objects import NullValue from matter_server.client.models.node import MatterNode from matter_server.common.errors import MatterError from matter_server.common.helpers.util import create_attribute_path_from_attribute @@ -188,3 +189,46 @@ async def test_matter_exception_on_command( }, blocking=True, ) + + +@pytest.mark.parametrize("node_fixture", ["silabs_evse_charging"]) +async def test_evse_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test evse sensors.""" + state = hass.states.get("switch.evse_enable_charging") + assert state + assert state.state == "on" + # test switch service + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": "switch.evse_enable_charging"}, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.EnergyEvse.Commands.Disable(), + timed_request_timeout_ms=3000, + ) + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.evse_enable_charging"}, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 2 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.EnergyEvse.Commands.EnableCharging( + chargingEnabledUntil=NullValue, + minimumChargeCurrent=0, + maximumChargeCurrent=0, + ), + timed_request_timeout_ms=3000, + ) diff --git a/tests/components/matter/test_update.py b/tests/components/matter/test_update.py index 92576fa69e2..b39edd156b8 100644 --- a/tests/components/matter/test_update.py +++ b/tests/components/matter/test_update.py @@ -86,7 +86,7 @@ async def test_update_entity( matter_node: MatterNode, ) -> None: """Test update entity exists and update check got made.""" - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_OFF @@ -101,7 +101,7 @@ async def test_update_check_service( matter_node: MatterNode, ) -> None: """Test check device update through service call.""" - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_OFF assert state.attributes.get("installed_version") == "v1.0" @@ -124,14 +124,14 @@ async def test_update_check_service( HA_DOMAIN, SERVICE_UPDATE_ENTITY, { - ATTR_ENTITY_ID: "update.mock_dimmable_light", + ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware", }, blocking=True, ) assert matter_client.check_node_update.call_count == 2 - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_ON assert state.attributes.get("latest_version") == "v2.0" @@ -150,7 +150,7 @@ async def test_update_install( freezer: FrozenDateTimeFactory, ) -> None: """Test device update with Matter attribute changes influence progress.""" - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_OFF assert state.attributes.get("installed_version") == "v1.0" @@ -173,7 +173,7 @@ async def test_update_install( assert matter_client.check_node_update.call_count == 2 - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_ON assert state.attributes.get("latest_version") == "v2.0" @@ -186,7 +186,7 @@ async def test_update_install( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: "update.mock_dimmable_light", + ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware", }, blocking=True, ) @@ -199,7 +199,7 @@ async def test_update_install( ) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_ON assert state.attributes["in_progress"] is True @@ -213,7 +213,7 @@ async def test_update_install( ) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_ON assert state.attributes["in_progress"] is True @@ -239,7 +239,7 @@ async def test_update_install( ) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state.state == STATE_OFF assert state.attributes.get("installed_version") == "v2.0" @@ -254,7 +254,7 @@ async def test_update_install_failure( freezer: FrozenDateTimeFactory, ) -> None: """Test update entity service call errors.""" - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_OFF assert state.attributes.get("installed_version") == "v1.0" @@ -277,7 +277,7 @@ async def test_update_install_failure( assert matter_client.check_node_update.call_count == 2 - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_ON assert state.attributes.get("latest_version") == "v2.0" @@ -293,7 +293,7 @@ async def test_update_install_failure( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: "update.mock_dimmable_light", + ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware", ATTR_VERSION: "v3.0", }, blocking=True, @@ -306,7 +306,7 @@ async def test_update_install_failure( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: "update.mock_dimmable_light", + ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware", ATTR_VERSION: "v3.0", }, blocking=True, @@ -323,7 +323,7 @@ async def test_update_state_save_and_restore( freezer: FrozenDateTimeFactory, ) -> None: """Test latest update information is retained across reload/restart.""" - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_OFF assert state.attributes.get("installed_version") == "v1.0" @@ -336,7 +336,7 @@ async def test_update_state_save_and_restore( assert matter_client.check_node_update.call_count == 2 - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_ON assert state.attributes.get("latest_version") == "v2.0" @@ -345,7 +345,7 @@ async def test_update_state_save_and_restore( assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] - assert state["entity_id"] == "update.mock_dimmable_light" + assert state["entity_id"] == "update.mock_dimmable_light_firmware" extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] # Check that the extra data has the format we expect. @@ -376,7 +376,7 @@ async def test_update_state_restore( ( ( State( - "update.mock_dimmable_light", + "update.mock_dimmable_light_firmware", STATE_ON, { "auto_update": False, @@ -393,7 +393,7 @@ async def test_update_state_restore( assert check_node_update.call_count == 0 - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_ON assert state.attributes.get("latest_version") == "v2.0" @@ -402,7 +402,7 @@ async def test_update_state_restore( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: "update.mock_dimmable_light", + ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware", }, blocking=True, ) diff --git a/tests/components/matter/test_water_heater.py b/tests/components/matter/test_water_heater.py new file mode 100644 index 00000000000..eb2ea9eb40e --- /dev/null +++ b/tests/components/matter/test_water_heater.py @@ -0,0 +1,246 @@ +"""Test Matter sensors.""" + +from unittest.mock import MagicMock, call + +from chip.clusters import Objects as clusters +from matter_server.client.models.node import MatterNode +from matter_server.common.helpers.util import create_attribute_path_from_attribute +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_HIGH_DEMAND, + STATE_OFF, + WaterHeaterEntityFeature, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ( + set_node_attribute, + snapshot_matter_entities, + trigger_subscription_callback, +) + + +@pytest.mark.usefixtures("matter_devices") +async def test_water_heaters( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test water heaters.""" + snapshot_matter_entities(hass, entity_registry, snapshot, Platform.WATER_HEATER) + + +@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) +async def test_water_heater( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test water heater entity.""" + state = hass.states.get("water_heater.water_heater") + assert state + assert state.attributes["min_temp"] == 40 + assert state.attributes["max_temp"] == 65 + assert state.attributes["temperature"] == 65 + assert state.attributes["operation_list"] == ["eco", "high_demand", "off"] + assert state.state == STATE_ECO + + # test supported features correctly parsed + mask = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE + ) + assert state.attributes["supported_features"] & mask == mask + + +@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) +async def test_water_heater_set_temperature( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test water_heater set temperature service.""" + # test single-setpoint temperature adjustment when eco mode is active + state = hass.states.get("water_heater.water_heater") + + assert state + assert state.state == STATE_ECO + await hass.services.async_call( + "water_heater", + "set_temperature", + { + "entity_id": "water_heater.water_heater", + "temperature": 52, + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path="2/513/18", + value=5200, + ) + matter_client.write_attribute.reset_mock() + + +@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) +@pytest.mark.parametrize( + ("operation_mode", "matter_attribute_value"), + [(STATE_OFF, 0), (STATE_ECO, 4), (STATE_HIGH_DEMAND, 4)], +) +async def test_water_heater_set_operation_mode( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, + operation_mode: str, + matter_attribute_value: int, +) -> None: + """Test water_heater set operation mode service.""" + state = hass.states.get("water_heater.water_heater") + assert state + + # test change mode to each operation_mode + await hass.services.async_call( + "water_heater", + "set_operation_mode", + { + "entity_id": "water_heater.water_heater", + "operation_mode": operation_mode, + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=2, + attribute=clusters.Thermostat.Attributes.SystemMode, + ), + value=matter_attribute_value, + ) + + +@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) +async def test_water_heater_boostmode( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test water_heater set operation mode service.""" + # Boost 1h (3600s) + boost_info: type[ + clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct + ] = clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct(duration=3600) + state = hass.states.get("water_heater.water_heater") + assert state + + # enable water_heater boostmode + await hass.services.async_call( + "water_heater", + "set_operation_mode", + { + "entity_id": "water_heater.water_heater", + "operation_mode": STATE_HIGH_DEMAND, + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=2, + attribute=clusters.Thermostat.Attributes.SystemMode, + ), + value=4, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=2, + command=clusters.WaterHeaterManagement.Commands.Boost(boostInfo=boost_info), + ) + + +@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) +async def test_update_from_water_heater( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test enable boost from water heater device side.""" + entity_id = "water_heater.water_heater" + + # confirm initial BoostState (as stored in the fixture) + state = hass.states.get(entity_id) + assert state + + # confirm thermostat state is 'high_demand' by setting the BoostState to 1 + set_node_attribute(matter_node, 2, 148, 5, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_HIGH_DEMAND + + # confirm thermostat state is 'eco' by setting the BoostState to 0 + set_node_attribute(matter_node, 2, 148, 5, 0) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ECO + + +@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) +async def test_water_heater_turn_on_off( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test water_heater set turn_off/turn_on.""" + state = hass.states.get("water_heater.water_heater") + assert state + + # turn_off water_heater + await hass.services.async_call( + "water_heater", + "turn_off", + { + "entity_id": "water_heater.water_heater", + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=2, + attribute=clusters.Thermostat.Attributes.SystemMode, + ), + value=0, + ) + + matter_client.write_attribute.reset_mock() + + # turn_on water_heater + await hass.services.async_call( + "water_heater", + "turn_on", + { + "entity_id": "water_heater.water_heater", + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=2, + attribute=clusters.Thermostat.Attributes.SystemMode, + ), + value=4, + ) diff --git a/tests/components/mcp/conftest.py b/tests/components/mcp/conftest.py index d86603a12ed..b6d6958d3d9 100644 --- a/tests/components/mcp/conftest.py +++ b/tests/components/mcp/conftest.py @@ -1,17 +1,34 @@ """Common fixtures for the Model Context Protocol tests.""" from collections.abc import Generator +import datetime from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.mcp.const import DOMAIN -from homeassistant.const import CONF_URL +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.mcp.const import ( + CONF_ACCESS_TOKEN, + CONF_AUTHORIZATION_URL, + CONF_TOKEN_URL, + DOMAIN, +) +from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry TEST_API_NAME = "Memory Server" +MCP_SERVER_URL = "http://1.1.1.1:8080/sse" +CLIENT_ID = "test-client-id" +CLIENT_SECRET = "test-client-secret" +AUTH_DOMAIN = "some-auth-domain" +OAUTH_AUTHORIZE_URL = "https://example-auth-server.com/authorize-path" +OAUTH_TOKEN_URL = "https://example-auth-server.com/token-path" @pytest.fixture @@ -29,6 +46,7 @@ def mock_mcp_client() -> Generator[AsyncMock]: with ( patch("homeassistant.components.mcp.coordinator.sse_client"), patch("homeassistant.components.mcp.coordinator.ClientSession") as mock_session, + patch("homeassistant.components.mcp.coordinator.TIMEOUT", 1), ): yield mock_session.return_value.__aenter__ @@ -43,3 +61,47 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: ) config_entry.add_to_hass(hass) return config_entry + + +@pytest.fixture(name="credential") +async def mock_credential(hass: HomeAssistant) -> None: + """Fixture that provides the ClientCredential for the test.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + AUTH_DOMAIN, + ) + + +@pytest.fixture(name="config_entry_token_expiration") +def mock_config_entry_token_expiration() -> datetime.datetime: + """Fixture to mock the token expiration.""" + return datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1) + + +@pytest.fixture(name="config_entry_with_auth") +def mock_config_entry_with_auth( + hass: HomeAssistant, + config_entry_token_expiration: datetime.datetime, +) -> MockConfigEntry: + """Fixture to load the integration with authentication.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=AUTH_DOMAIN, + data={ + "auth_implementation": AUTH_DOMAIN, + CONF_URL: MCP_SERVER_URL, + CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL, + CONF_TOKEN_URL: OAUTH_TOKEN_URL, + CONF_TOKEN: { + CONF_ACCESS_TOKEN: "test-access-token", + "refresh_token": "test-refresh-token", + "expires_at": config_entry_token_expiration.timestamp(), + }, + }, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/mcp/test_config_flow.py b/tests/components/mcp/test_config_flow.py index 29733e653a6..426b3267195 100644 --- a/tests/components/mcp/test_config_flow.py +++ b/tests/components/mcp/test_config_flow.py @@ -1,20 +1,70 @@ """Test the Model Context Protocol config flow.""" +import json from typing import Any from unittest.mock import AsyncMock, Mock import httpx import pytest +import respx from homeassistant import config_entries -from homeassistant.components.mcp.const import DOMAIN -from homeassistant.const import CONF_URL +from homeassistant.components.mcp.const import ( + CONF_AUTHORIZATION_URL, + CONF_TOKEN_URL, + DOMAIN, +) +from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow -from .conftest import TEST_API_NAME +from .conftest import ( + AUTH_DOMAIN, + CLIENT_ID, + MCP_SERVER_URL, + OAUTH_AUTHORIZE_URL, + OAUTH_TOKEN_URL, + TEST_API_NAME, +) from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +MCP_SERVER_BASE_URL = "http://1.1.1.1:8080" +OAUTH_DISCOVERY_ENDPOINT = ( + f"{MCP_SERVER_BASE_URL}/.well-known/oauth-authorization-server" +) +OAUTH_SERVER_METADATA_RESPONSE = httpx.Response( + status_code=200, + text=json.dumps( + { + "authorization_endpoint": OAUTH_AUTHORIZE_URL, + "token_endpoint": OAUTH_TOKEN_URL, + } + ), +) +CALLBACK_PATH = "/auth/external/callback" +OAUTH_CALLBACK_URL = f"https://example.com{CALLBACK_PATH}" +OAUTH_CODE = "abcd" +OAUTH_TOKEN_PAYLOAD = { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, +} + + +def encode_state(hass: HomeAssistant, flow_id: str) -> str: + """Encode the OAuth JWT.""" + return config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": flow_id, + "redirect_uri": OAUTH_CALLBACK_URL, + }, + ) async def test_form( @@ -34,15 +84,19 @@ async def test_form( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TEST_API_NAME assert result["data"] == { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, } + # Config entry does not have a unique id + assert result["result"] + assert result["result"].unique_id is None + assert len(mock_setup_entry.mock_calls) == 1 @@ -73,7 +127,7 @@ async def test_form_mcp_client_error( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) @@ -89,50 +143,18 @@ async def test_form_mcp_client_error( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TEST_API_NAME assert result["data"] == { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, } assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize( - ("side_effect", "expected_error"), - [ - ( - httpx.HTTPStatusError("", request=None, response=httpx.Response(401)), - "invalid_auth", - ), - ], -) -async def test_form_mcp_client_error_abort( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_mcp_client: Mock, - side_effect: Exception, - expected_error: str, -) -> None: - """Test we handle different client library errors that end with an abort.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - mock_mcp_client.side_effect = side_effect - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: "http://1.1.1.1/sse", - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == expected_error - - @pytest.mark.parametrize( "user_input", [ @@ -165,14 +187,14 @@ async def test_input_form_validation_error( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TEST_API_NAME assert result["data"] == { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, } assert len(mock_setup_entry.mock_calls) == 1 @@ -183,7 +205,7 @@ async def test_unique_url( """Test that the same url cannot be configured twice.""" config_entry = MockConfigEntry( domain=DOMAIN, - data={CONF_URL: "http://1.1.1.1/sse"}, + data={CONF_URL: MCP_SERVER_URL}, title=TEST_API_NAME, ) config_entry.add_to_hass(hass) @@ -201,7 +223,7 @@ async def test_unique_url( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) @@ -226,9 +248,409 @@ async def test_server_missing_capbilities( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "missing_capabilities" + + +@respx.mock +async def test_oauth_discovery_flow_without_credentials( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, +) -> None: + """Test for an OAuth discoveryflow for an MCP server where the user has not yet entered credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + + # The config flow will abort and the user will be taken to the application credentials UI + # to enter their credentials. + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_credentials" + + +async def perform_oauth_flow( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + result: config_entries.ConfigFlowResult, + authorize_url: str = OAUTH_AUTHORIZE_URL, + token_url: str = OAUTH_TOKEN_URL, +) -> config_entries.ConfigFlowResult: + """Perform the common steps of the OAuth flow. + + Expects to be called from the step where the user selects credentials. + """ + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": OAUTH_CALLBACK_URL, + }, + ) + assert result["url"] == ( + f"{authorize_url}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={OAUTH_CALLBACK_URL}" + f"&state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"{CALLBACK_PATH}?code={OAUTH_CODE}&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + token_url, + json=OAUTH_TOKEN_PAYLOAD, + ) + + return result + + +@pytest.mark.parametrize( + ("oauth_server_metadata_response", "expected_authorize_url", "expected_token_url"), + [ + (OAUTH_SERVER_METADATA_RESPONSE, OAUTH_AUTHORIZE_URL, OAUTH_TOKEN_URL), + ( + httpx.Response( + status_code=200, + text=json.dumps( + { + "authorization_endpoint": "/authorize-path", + "token_endpoint": "/token-path", + } + ), + ), + f"{MCP_SERVER_BASE_URL}/authorize-path", + f"{MCP_SERVER_BASE_URL}/token-path", + ), + ( + httpx.Response(status_code=404), + f"{MCP_SERVER_BASE_URL}/authorize", + f"{MCP_SERVER_BASE_URL}/token", + ), + ], + ids=( + "discovery", + "relative_paths", + "no_discovery_metadata", + ), +) +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_authentication_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + oauth_server_metadata_response: httpx.Response, + expected_authorize_url: str, + expected_token_url: str, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=oauth_server_metadata_response + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "next_step_id": "pick_implementation", + }, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + result = await perform_oauth_flow( + hass, + aioclient_mock, + hass_client_no_auth, + result, + authorize_url=expected_authorize_url, + token_url=expected_token_url, + ) + + # Client now accepts credentials + mock_mcp_client.side_effect = None + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_API_NAME + data = result["data"] + token = data.pop(CONF_TOKEN) + assert data == { + "auth_implementation": AUTH_DOMAIN, + CONF_URL: MCP_SERVER_URL, + CONF_AUTHORIZATION_URL: expected_authorize_url, + CONF_TOKEN_URL: expected_token_url, + } + assert token + token.pop("expires_at") + assert token == OAUTH_TOKEN_PAYLOAD + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (httpx.TimeoutException("Some timeout"), "timeout_connect"), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(500)), + "cannot_connect", + ), + (httpx.HTTPError("Some HTTP error"), "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_oauth_discovery_failure( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + side_effect: Exception, + expected_error: str, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock(side_effect=side_effect) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_error + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (httpx.TimeoutException("Some timeout"), "timeout_connect"), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(500)), + "cannot_connect", + ), + (httpx.HTTPError("Some HTTP error"), "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_authentication_flow_server_failure_abort( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + side_effect: Exception, + expected_error: str, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "next_step_id": "pick_implementation", + }, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + result = await perform_oauth_flow( + hass, + aioclient_mock, + hass_client_no_auth, + result, + ) + + # Client fails with an error + mock_mcp_client.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_error + + +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_authentication_flow_server_missing_tool_capabilities( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "next_step_id": "pick_implementation", + }, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + result = await perform_oauth_flow( + hass, + aioclient_mock, + hass_client_no_auth, + result, + ) + + # Client can now authenticate + mock_mcp_client.side_effect = None + + response = Mock() + response.serverInfo.name = TEST_API_NAME + response.capabilities.tools = None + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_capabilities" + + +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_reauth_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + config_entry_with_auth: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + config_entry_with_auth.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + result = await perform_oauth_flow(hass, aioclient_mock, hass_client_no_auth, result) + + # Verify we can connect to the server + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry_with_auth.unique_id == AUTH_DOMAIN + assert config_entry_with_auth.title == TEST_API_NAME + data = {**config_entry_with_auth.data} + token = data.pop(CONF_TOKEN) + assert data == { + "auth_implementation": AUTH_DOMAIN, + CONF_URL: MCP_SERVER_URL, + CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL, + CONF_TOKEN_URL: OAUTH_TOKEN_URL, + } + assert token + token.pop("expires_at") + assert token == OAUTH_TOKEN_PAYLOAD + + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index 460df2c5785..045fb99e181 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -76,17 +76,45 @@ async def test_init( assert config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize( + ("side_effect"), + [ + (httpx.TimeoutException("Some timeout")), + (httpx.HTTPStatusError("", request=None, response=httpx.Response(500))), + (httpx.HTTPStatusError("", request=None, response=httpx.Response(401))), + (httpx.HTTPError("Some HTTP error")), + ], +) async def test_mcp_server_failure( - hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, + side_effect: Exception, ) -> None: """Test the integration fails to setup if the server fails initialization.""" + mock_mcp_client.side_effect = side_effect + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_mcp_server_authentication_failure( + hass: HomeAssistant, + credential: None, + config_entry_with_auth: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test the integration fails to setup if the server fails authentication.""" mock_mcp_client.side_effect = httpx.HTTPStatusError( - "", request=None, response=httpx.Response(500) + "Authentication required", request=None, response=httpx.Response(401) ) - with patch("homeassistant.components.mcp.coordinator.TIMEOUT", 1): - await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.state is ConfigEntryState.SETUP_RETRY + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + assert config_entry_with_auth.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" async def test_list_tools_failure( diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index 70efd211b57..61cd1a4dd02 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -315,7 +315,7 @@ async def test_mcp_tools_list( # are converted correctly. tool = next(iter(tool for tool in result.tools if tool.name == "HassTurnOn")) assert tool.name == "HassTurnOn" - assert tool.description == "Turns on/opens a device or entity" + assert tool.description is not None assert tool.inputSchema assert tool.inputSchema.get("type") == "object" properties = tool.inputSchema.get("properties") diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 1878d7372f6..090ea9f27e2 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -12,13 +12,20 @@ from homeassistant.components import media_player from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_FILTER_CLASSES, + ATTR_MEDIA_SEARCH_QUERY, BrowseMedia, MediaClass, MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, + SearchMedia, + SearchMediaQuery, +) +from homeassistant.components.media_player.const import ( + SERVICE_BROWSE_MEDIA, + SERVICE_SEARCH_MEDIA, ) -from homeassistant.components.media_player.const import SERVICE_BROWSE_MEDIA from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF from homeassistant.core import HomeAssistant @@ -47,6 +54,7 @@ def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, s not in [ MediaPlayerEntityFeature.MEDIA_ANNOUNCE, MediaPlayerEntityFeature.MEDIA_ENQUEUE, + MediaPlayerEntityFeature.SEARCH_MEDIA, ] ] @@ -315,6 +323,7 @@ async def test_media_browse( "media_content_id": "mock-id", "can_play": False, "can_expand": True, + "can_search": False, "children_media_class": None, "thumbnail": None, "not_shown": 0, @@ -411,6 +420,119 @@ async def test_media_browse_service(hass: HomeAssistant) -> None: assert browse_res.children[1].media_content_type == "album" +async def test_media_search( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test browsing media.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.demo.media_player.DemoSearchPlayer.async_search_media", + return_value=SearchMedia( + result=[ + BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id="mock-id", + media_content_type="mock-type", + title="Mock Title", + can_play=False, + can_expand=True, + ) + ] + ), + ) as mock_search_media: + await client.send_json( + { + "id": 7, + "type": "media_player/search_media", + "entity_id": "media_player.search", + "media_content_type": "album", + "media_content_id": "abcd", + "search_query": "query", + "media_filter_classes": ["album"], + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"]["result"] == [ + { + "title": "Mock Title", + "media_class": "directory", + "media_content_type": "mock-type", + "media_content_id": "mock-id", + "children_media_class": None, + "can_play": False, + "can_expand": True, + "can_search": False, + "thumbnail": None, + "not_shown": 0, + "children": [], + } + ] + assert mock_search_media.mock_calls[0].kwargs["query"] == SearchMediaQuery( + search_query="query", + media_content_type="album", + media_content_id="abcd", + media_filter_classes={MediaClass.ALBUM}, + ) + + +async def test_media_search_service(hass: HomeAssistant) -> None: + """Test browsing media.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + expected = [ + BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id="mock-id", + media_content_type="mock-type", + title="Mock Title", + can_play=False, + can_expand=True, + children=[], + ) + ] + + with patch( + "homeassistant.components.demo.media_player.DemoSearchPlayer.async_search_media", + return_value=SearchMedia(result=expected), + ) as mock_search_media: + result = await hass.services.async_call( + "media_player", + SERVICE_SEARCH_MEDIA, + { + ATTR_ENTITY_ID: "media_player.search", + ATTR_MEDIA_CONTENT_TYPE: "album", + ATTR_MEDIA_CONTENT_ID: "title=Album*", + ATTR_MEDIA_SEARCH_QUERY: "query", + ATTR_MEDIA_FILTER_CLASSES: ["album"], + }, + blocking=True, + return_response=True, + ) + + search_res: SearchMedia = result["media_player.search"] + assert search_res.version == 1 + assert search_res.result == expected + assert mock_search_media.mock_calls[0].kwargs["query"] == SearchMediaQuery( + search_query="query", + media_content_type="album", + media_content_id="title=Album*", + media_filter_classes={MediaClass.ALBUM}, + ) + + async def test_group_members_available_when_off(hass: HomeAssistant) -> None: """Test that group_members are still available when media_player is off.""" await async_setup_component( diff --git a/tests/components/media_player/test_intent.py b/tests/components/media_player/test_intent.py index 9ddf50d04f4..8e7211183e7 100644 --- a/tests/components/media_player/test_intent.py +++ b/tests/components/media_player/test_intent.py @@ -104,19 +104,6 @@ async def test_unpause_media_player_intent(hass: HomeAssistant) -> None: assert call.service == SERVICE_MEDIA_PLAY assert call.data == {"entity_id": entity_id} - # Test if not paused - hass.states.async_set( - entity_id, - STATE_PLAYING, - ) - - with pytest.raises(intent.MatchFailedError): - response = await intent.async_handle( - hass, - "test", - media_player_intent.INTENT_MEDIA_UNPAUSE, - ) - async def test_next_media_player_intent(hass: HomeAssistant) -> None: """Test HassMediaNext intent for media players.""" @@ -245,17 +232,6 @@ async def test_volume_media_player_intent(hass: HomeAssistant) -> None: assert call.service == SERVICE_VOLUME_SET assert call.data == {"entity_id": entity_id, "volume_level": 0.5} - # Test if not playing - hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes) - - with pytest.raises(intent.MatchFailedError): - response = await intent.async_handle( - hass, - "test", - media_player_intent.INTENT_SET_VOLUME, - {"volume_level": {"value": 50}}, - ) - # Test feature not supported hass.states.async_set( entity_id, diff --git a/tests/components/meraki/test_device_tracker.py b/tests/components/meraki/test_device_tracker.py index 139396a0689..c187ca8ce75 100644 --- a/tests/components/meraki/test_device_tracker.py +++ b/tests/components/meraki/test_device_tracker.py @@ -1,6 +1,5 @@ """The tests the for Meraki device tracker.""" -from asyncio import AbstractEventLoop from http import HTTPStatus import json @@ -22,31 +21,25 @@ from tests.typing import ClientSessionGenerator @pytest.fixture -def meraki_client( - event_loop: AbstractEventLoop, +async def meraki_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Meraki mock client.""" - loop = event_loop + assert await async_setup_component( + hass, + device_tracker.DOMAIN, + { + device_tracker.DOMAIN: { + CONF_PLATFORM: "meraki", + CONF_VALIDATOR: "validator", + CONF_SECRET: "secret", + } + }, + ) + await hass.async_block_till_done() - async def setup_and_wait(): - result = await async_setup_component( - hass, - device_tracker.DOMAIN, - { - device_tracker.DOMAIN: { - CONF_PLATFORM: "meraki", - CONF_VALIDATOR: "validator", - CONF_SECRET: "secret", - } - }, - ) - await hass.async_block_till_done() - return result - - assert loop.run_until_complete(setup_and_wait()) - return loop.run_until_complete(hass_client()) + return await hass_client() async def test_invalid_or_missing_data( diff --git a/tests/components/meteo_france/snapshots/test_weather.ambr b/tests/components/meteo_france/snapshots/test_weather.ambr index 7c64ee86671..d5e03c95de2 100644 --- a/tests/components/meteo_france/snapshots/test_weather.ambr +++ b/tests/components/meteo_france/snapshots/test_weather.ambr @@ -47,6 +47,7 @@ 'temperature_unit': , 'visibility_unit': , 'wind_bearing': 200, + 'wind_gust_speed': 64.8, 'wind_speed': 28.8, 'wind_speed_unit': , }), diff --git a/tests/components/miele/__init__.py b/tests/components/miele/__init__.py new file mode 100644 index 00000000000..b0278defa8e --- /dev/null +++ b/tests/components/miele/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Miele 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/miele/conftest.py b/tests/components/miele/conftest.py new file mode 100644 index 00000000000..6df5b73ccc2 --- /dev/null +++ b/tests/components/miele/conftest.py @@ -0,0 +1,159 @@ +"""Test helpers for Miele.""" + +from collections.abc import AsyncGenerator, Generator +import time +from unittest.mock import AsyncMock, MagicMock, patch + +from pymiele import MieleAction, MieleDevices +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.miele.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import CLIENT_ID, CLIENT_SECRET + +from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> float: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant, expires_at: float) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + minor_version=1, + domain=DOMAIN, + title="Miele test", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "Fake_token", + "expires_in": 86399, + "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", + "token_type": "Bearer", + "expires_at": expires_at, + }, + }, + entry_id="miele_test", + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + CLIENT_ID, + CLIENT_SECRET, + ), + DOMAIN, + ) + + +# Fixture group for device API endpoint. + + +@pytest.fixture(scope="package") +def load_device_file() -> str: + """Fixture for loading device file.""" + return "4_devices.json" + + +@pytest.fixture +def device_fixture(load_device_file: str) -> MieleDevices: + """Fixture for device.""" + return load_json_object_fixture(load_device_file, DOMAIN) + + +@pytest.fixture(scope="package") +def load_action_file() -> str: + """Fixture for loading action file.""" + return "action_washing_machine.json" + + +@pytest.fixture +def action_fixture(load_action_file: str) -> MieleAction: + """Fixture for action.""" + return load_json_object_fixture(load_action_file, DOMAIN) + + +@pytest.fixture(scope="package") +def load_programs_file() -> str: + """Fixture for loading programs file.""" + return "programs_washing_machine.json" + + +@pytest.fixture +def programs_fixture(load_programs_file: str) -> list[dict]: + """Fixture for available programs.""" + return load_fixture(load_programs_file, DOMAIN) + + +@pytest.fixture +def mock_miele_client( + device_fixture, + action_fixture, + programs_fixture, +) -> Generator[MagicMock]: + """Mock a Miele client.""" + + with patch( + "homeassistant.components.miele.AsyncConfigEntryAuth", + autospec=True, + ) as mock_client: + client = mock_client.return_value + + client.get_devices.return_value = device_fixture + client.get_actions.return_value = action_fixture + client.get_programs.return_value = programs_fixture + + yield client + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture for platforms.""" + return [] + + +@pytest.fixture +async def setup_platform( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + platforms, +) -> AsyncGenerator[None]: + """Set up one or all platforms.""" + + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + yield + + +@pytest.fixture +async def access_token(hass: HomeAssistant) -> str: + """Return a valid access token.""" + return "mock-access-token" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.miele.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/miele/const.py b/tests/components/miele/const.py new file mode 100644 index 00000000000..fdc709229d2 --- /dev/null +++ b/tests/components/miele/const.py @@ -0,0 +1,5 @@ +"""Constants for miele tests.""" + +CLIENT_ID = "12345" +CLIENT_SECRET = "67890" +UNIQUE_ID = "uid" diff --git a/tests/components/miele/fixtures/4_devices.json b/tests/components/miele/fixtures/4_devices.json new file mode 100644 index 00000000000..b63c60ff4d3 --- /dev/null +++ b/tests/components/miele/fixtures/4_devices.json @@ -0,0 +1,470 @@ +{ + "Dummy_Appliance_1": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 20, + "value_localized": "Freezer" + }, + "deviceName": "", + "protocolVersion": 201, + "deviceIdentLabel": { + "fabNumber": "Dummy_Appliance_1", + "fabIndex": "21", + "techType": "FNS 28463 E ed/", + "matNumber": "10805070", + "swids": ["4497"] + }, + "xkmIdentLabel": { + "techType": "EK042", + "releaseVersion": "31.17" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -1800, + "value_localized": -18, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [], + "temperature": [ + { + "value_raw": -1800, + "value_localized": -18, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "Dummy_Appliance_2": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 19, + "value_localized": "Refrigerator" + }, + "deviceName": "", + "protocolVersion": 201, + "deviceIdentLabel": { + "fabNumber": "Dummy_Appliance_2", + "fabIndex": "17", + "techType": "KS 28423 D ed/c", + "matNumber": "10804770", + "swids": ["4497"] + }, + "xkmIdentLabel": { + "techType": "EK042", + "releaseVersion": "31.17" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": 400, + "value_localized": 4, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [], + "temperature": [ + { + "value_raw": 400, + "value_localized": 4, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "Dummy_Appliance_3": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 1, + "value_localized": "Washing machine" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "Dummy_Appliance_3", + "fabIndex": "44", + "techType": "WCI870", + "matNumber": "11387290", + "swids": [ + "5975", + "20456", + "25213", + "25191", + "25446", + "25205", + "25447", + "25319" + ] + }, + "xkmIdentLabel": { + "techType": "EK057", + "releaseVersion": "08.32" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": true, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": { + "currentWaterConsumption": { + "unit": "l", + "value": 0.0 + }, + "currentEnergyConsumption": { + "unit": "kWh", + "value": 0.0 + }, + "waterForecast": 0.0, + "energyForecast": 0.1 + }, + "batteryLevel": null + } + }, + "DummyAppliance_18": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 18, + "value_localized": "Cooker Hood" + }, + "deviceName": "", + "protocolVersion": 2, + "deviceIdentLabel": { + "fabNumber": "", + "fabIndex": "64", + "techType": "Fläkt", + "matNumber": "", + "swids": ["", "", "", "<...>"] + }, + "xkmIdentLabel": { + "techType": "EK039W", + "releaseVersion": "02.72" + } + }, + "state": { + "ProgramID": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "Program name" + }, + "status": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "Program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 4608, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": 2, + "light": 1, + "elapsedTime": {}, + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": 0, + "value_localized": "0", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/fixtures/5_devices.json b/tests/components/miele/fixtures/5_devices.json new file mode 100644 index 00000000000..93b5bf9f887 --- /dev/null +++ b/tests/components/miele/fixtures/5_devices.json @@ -0,0 +1,534 @@ +{ + "Dummy_Appliance_1": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 20, + "value_localized": "Freezer" + }, + "deviceName": "", + "protocolVersion": 201, + "deviceIdentLabel": { + "fabNumber": "Dummy_Appliance_1", + "fabIndex": "21", + "techType": "FNS 28463 E ed/", + "matNumber": "10805070", + "swids": ["4497"] + }, + "xkmIdentLabel": { + "techType": "EK042", + "releaseVersion": "31.17" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [], + "targetTemperature": [ + { + "value_raw": -1800, + "value_localized": -18, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [], + "temperature": [ + { + "value_raw": -1800, + "value_localized": -18, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "Dummy_Appliance_2": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 19, + "value_localized": "Refrigerator" + }, + "deviceName": "", + "protocolVersion": 201, + "deviceIdentLabel": { + "fabNumber": "Dummy_Appliance_2", + "fabIndex": "17", + "techType": "KS 28423 D ed/c", + "matNumber": "10804770", + "swids": ["4497"] + }, + "xkmIdentLabel": { + "techType": "EK042", + "releaseVersion": "31.17" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": 400, + "value_localized": 4, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [], + "temperature": [ + { + "value_raw": 400, + "value_localized": 4, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "Dummy_Appliance_3": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 1, + "value_localized": "Washing machine" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "Dummy_Appliance_3", + "fabIndex": "44", + "techType": "WCI870", + "matNumber": "11387290", + "swids": [ + "5975", + "20456", + "25213", + "25191", + "25446", + "25205", + "25447", + "25319" + ] + }, + "xkmIdentLabel": { + "techType": "EK057", + "releaseVersion": "08.32" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": true, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "Dummy_Appliance_4": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 12, + "value_localized": "Oven" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "", + "fabIndex": "00", + "techType": "H7264B", + "matNumber": "", + "swids": ["swid00"] + }, + "xkmIdentLabel": { "techType": "EK057", "releaseVersion": "08.21" } + }, + "state": { + "ProgramID": { + "value_raw": 13, + "value_localized": "Fan plus", + "key_localized": "Program name" + }, + "status": { + "value_raw": 3, + "value_localized": "Programmed", + "key_localized": "status" + }, + "programType": { + "value_raw": 1, + "value_localized": "Own program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { "value_raw": 18000, "value_localized": "180.0", "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" } + ], + "temperature": [ + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": 2, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "Dummy_Appliance_5": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 7, + "value_localized": "Dishwasher" + }, + "deviceName": "", + "protocolVersion": 2, + "deviceIdentLabel": { + "fabNumber": "", + "fabIndex": "64", + "techType": "G6865-W", + "matNumber": "", + "swids": ["", "", "", "<...>"] + }, + "xkmIdentLabel": { "techType": "EK039W", "releaseVersion": "02.72" } + }, + "state": { + "ProgramID": { + "value_raw": 99938, + "value_localized": "QuickPowerWash", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 9992, + "value_localized": "Automatic programme", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 9991799, + "value_localized": "Drying", + "key_localized": "Program phase" + }, + "remainingTime": [0, 15], + "startTime": [0, 0], + "targetTemperature": [ + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" } + ], + "temperature": [ + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [0, 59], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": { + "currentWaterConsumption": { + "unit": "l", + "value": 12 + }, + "currentEnergyConsumption": { + "unit": "kWh", + "value": 1.4 + }, + "waterForecast": 0.2, + "energyForecast": 0.1 + }, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/fixtures/action_freezer.json b/tests/components/miele/fixtures/action_freezer.json new file mode 100644 index 00000000000..1d6e8832bae --- /dev/null +++ b/tests/components/miele/fixtures/action_freezer.json @@ -0,0 +1,21 @@ +{ + "processAction": [4], + "light": [], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [ + { + "zone": 1, + "min": -28, + "max": -14 + } + ], + "deviceName": true, + "powerOn": false, + "powerOff": false, + "colors": [], + "modes": [1], + "runOnTime": [] +} diff --git a/tests/components/miele/fixtures/action_fridge.json b/tests/components/miele/fixtures/action_fridge.json new file mode 100644 index 00000000000..9bfc7810a41 --- /dev/null +++ b/tests/components/miele/fixtures/action_fridge.json @@ -0,0 +1,21 @@ +{ + "processAction": [6], + "light": [], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [ + { + "zone": 1, + "min": 1, + "max": 9 + } + ], + "deviceName": true, + "powerOn": false, + "powerOff": false, + "colors": [], + "modes": [1], + "runOnTime": [] +} diff --git a/tests/components/miele/fixtures/action_washing_machine.json b/tests/components/miele/fixtures/action_washing_machine.json new file mode 100644 index 00000000000..5e8e00306f4 --- /dev/null +++ b/tests/components/miele/fixtures/action_washing_machine.json @@ -0,0 +1,15 @@ +{ + "processAction": [1, 2, 3], + "light": [], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [], + "deviceName": true, + "powerOn": true, + "powerOff": false, + "colors": [], + "modes": [], + "runOnTime": [] +} diff --git a/tests/components/miele/fixtures/fan_devices.json b/tests/components/miele/fixtures/fan_devices.json new file mode 100644 index 00000000000..9904f6f5faa --- /dev/null +++ b/tests/components/miele/fixtures/fan_devices.json @@ -0,0 +1,338 @@ +{ + "DummyAppliance_18": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 18, + "value_localized": "Cooker Hood" + }, + "deviceName": "", + "protocolVersion": 2, + "deviceIdentLabel": { + "fabNumber": "", + "fabIndex": "64", + "techType": "Fläkt", + "matNumber": "", + "swids": ["", "", "", "<...>"] + }, + "xkmIdentLabel": { + "techType": "EK039W", + "releaseVersion": "02.72" + } + }, + "state": { + "ProgramID": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "Program name" + }, + "status": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "Program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 4608, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": 2, + "light": 1, + "elapsedTime": {}, + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": 0, + "value_localized": "0", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "DummyAppliance_74": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 74, + "value_localized": "Hob w extraction" + }, + "deviceName": "", + "protocolVersion": 203, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "00", + "techType": "KMDA7634", + "matNumber": "", + "swids": ["000"] + }, + "xkmIdentLabel": { + "techType": "EK039W", + "releaseVersion": "02.72" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "Program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" } + ], + "temperature": [ + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": "", + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": 1, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [ + { + "value_raw": 0, + "value_localized": 0, + "key_localized": "Power level" + }, + { + "value_raw": 3, + "value_localized": 2, + "key_localized": "Power level" + }, + { + "value_raw": 7, + "value_localized": 4, + "key_localized": "Power level" + }, + { + "value_raw": 15, + "value_localized": 8, + "key_localized": "Power level" + }, + { + "value_raw": 117, + "value_localized": 10, + "key_localized": "Power level" + } + ], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "DummyAppliance_74_off": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 74, + "value_localized": "Hob with vapour extraction" + }, + "deviceName": "", + "protocolVersion": 2, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "00", + "techType": "KMDA7473", + "matNumber": "", + "swids": ["000"] + }, + "xkmIdentLabel": { + "techType": "EK039W", + "releaseVersion": "02.80" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": false, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/fixtures/programs_washing_machine.json b/tests/components/miele/fixtures/programs_washing_machine.json new file mode 100644 index 00000000000..a3c16ece8e6 --- /dev/null +++ b/tests/components/miele/fixtures/programs_washing_machine.json @@ -0,0 +1,117 @@ +[ + { + "programId": 146, + "program": "QuickPowerWash", + "parameters": {} + }, + { + "programId": 123, + "program": "Dark garments / Denim", + "parameters": {} + }, + { + "programId": 190, + "program": "ECO 40-60 ", + "parameters": {} + }, + { + "programId": 27, + "program": "Proofing", + "parameters": {} + }, + { + "programId": 23, + "program": "Shirts", + "parameters": {} + }, + { + "programId": 9, + "program": "Silks ", + "parameters": {} + }, + { + "programId": 8, + "program": "Woollens ", + "parameters": {} + }, + { + "programId": 4, + "program": "Delicates", + "parameters": {} + }, + { + "programId": 3, + "program": "Minimum iron", + "parameters": {} + }, + { + "programId": 1, + "program": "Cottons", + "parameters": {} + }, + { + "programId": 69, + "program": "Cottons hygiene", + "parameters": {} + }, + { + "programId": 37, + "program": "Outerwear", + "parameters": {} + }, + { + "programId": 122, + "program": "Express 20", + "parameters": {} + }, + { + "programId": 29, + "program": "Sportswear", + "parameters": {} + }, + { + "programId": 31, + "program": "Automatic plus", + "parameters": {} + }, + { + "programId": 39, + "program": "Pillows", + "parameters": {} + }, + { + "programId": 22, + "program": "Curtains", + "parameters": {} + }, + { + "programId": 129, + "program": "Down filled items", + "parameters": {} + }, + { + "programId": 53, + "program": "First wash", + "parameters": {} + }, + { + "programId": 95, + "program": "Down duvets", + "parameters": {} + }, + { + "programId": 52, + "program": "Separate rinse / Starch", + "parameters": {} + }, + { + "programId": 21, + "program": "Drain / Spin", + "parameters": {} + }, + { + "programId": 91, + "program": "Clean machine", + "parameters": {} + } +] diff --git a/tests/components/miele/snapshots/test_binary_sensor.ambr b/tests/components/miele/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..9f5b886b0ba --- /dev/null +++ b/tests/components/miele/snapshots/test_binary_sensor.ambr @@ -0,0 +1,1093 @@ +# serializer version: 1 +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.freezer_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': 'Door', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_1-state_signal_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Freezer Door', + }), + 'context': , + 'entity_id': 'binary_sensor.freezer_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_mobile_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.freezer_mobile_start', + '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': 'Mobile start', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mobile_start', + 'unique_id': 'Dummy_Appliance_1-state_mobile_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_mobile_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Freezer Mobile start', + }), + 'context': , + 'entity_id': 'binary_sensor.freezer_mobile_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_notification_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.freezer_notification_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Notification active', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_active', + 'unique_id': 'Dummy_Appliance_1-state_signal_info', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_notification_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Freezer Notification active', + }), + 'context': , + 'entity_id': 'binary_sensor.freezer_notification_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.freezer_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_1-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Freezer Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.freezer_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.freezer_remote_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': 'Remote control', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'Dummy_Appliance_1-state_full_remote_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Freezer Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.freezer_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_smart_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.freezer_smart_grid', + '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': 'Smart grid', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'smart_grid', + 'unique_id': 'Dummy_Appliance_1-state_smart_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_smart_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Freezer Smart grid', + }), + 'context': , + 'entity_id': 'binary_sensor.freezer_smart_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_mobile_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.hood_mobile_start', + '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': 'Mobile start', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mobile_start', + 'unique_id': 'DummyAppliance_18-state_mobile_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_mobile_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hood Mobile start', + }), + 'context': , + 'entity_id': 'binary_sensor.hood_mobile_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_notification_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.hood_notification_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Notification active', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_active', + 'unique_id': 'DummyAppliance_18-state_signal_info', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_notification_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Hood Notification active', + }), + 'context': , + 'entity_id': 'binary_sensor.hood_notification_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.hood_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_18-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Hood Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.hood_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.hood_remote_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': 'Remote control', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'DummyAppliance_18-state_full_remote_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hood Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.hood_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_smart_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.hood_smart_grid', + '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': 'Smart grid', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'smart_grid', + 'unique_id': 'DummyAppliance_18-state_smart_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_smart_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hood Smart grid', + }), + 'context': , + 'entity_id': 'binary_sensor.hood_smart_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.refrigerator_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': 'Door', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_2-state_signal_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator Door', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_mobile_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.refrigerator_mobile_start', + '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': 'Mobile start', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mobile_start', + 'unique_id': 'Dummy_Appliance_2-state_mobile_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_mobile_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Mobile start', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_mobile_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_notification_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.refrigerator_notification_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Notification active', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_active', + 'unique_id': 'Dummy_Appliance_2-state_signal_info', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_notification_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Refrigerator Notification active', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_notification_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.refrigerator_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_2-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Refrigerator Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.refrigerator_remote_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': 'Remote control', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'Dummy_Appliance_2-state_full_remote_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_smart_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.refrigerator_smart_grid', + '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': 'Smart grid', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'smart_grid', + 'unique_id': 'Dummy_Appliance_2-state_smart_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_smart_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Smart grid', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_smart_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washing_machine_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': 'Door', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_3-state_signal_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Washing machine Door', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_mobile_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.washing_machine_mobile_start', + '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': 'Mobile start', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mobile_start', + 'unique_id': 'Dummy_Appliance_3-state_mobile_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_mobile_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Mobile start', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_mobile_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_notification_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.washing_machine_notification_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Notification active', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_active', + 'unique_id': 'Dummy_Appliance_3-state_signal_info', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_notification_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Washing machine Notification active', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_notification_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.washing_machine_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_3-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Washing machine Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.washing_machine_remote_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': 'Remote control', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'Dummy_Appliance_3-state_full_remote_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_smart_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.washing_machine_smart_grid', + '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': 'Smart grid', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'smart_grid', + 'unique_id': 'Dummy_Appliance_3-state_smart_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_smart_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Smart grid', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_smart_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/miele/snapshots/test_button.ambr b/tests/components/miele/snapshots/test_button.ambr new file mode 100644 index 00000000000..b4f5ea5685a --- /dev/null +++ b/tests/components/miele/snapshots/test_button.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_button_states[platforms0][button.hood_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.hood_stop', + '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': 'Stop', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': 'DummyAppliance_18-stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.hood_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hood Stop', + }), + 'context': , + 'entity_id': 'button.hood_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_pause-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.washing_machine_pause', + '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': 'Pause', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pause', + 'unique_id': 'Dummy_Appliance_3-pause', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_pause-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Pause', + }), + 'context': , + 'entity_id': 'button.washing_machine_pause', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.washing_machine_start', + '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': 'Start', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'Dummy_Appliance_3-start', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Start', + }), + 'context': , + 'entity_id': 'button.washing_machine_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.washing_machine_stop', + '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': 'Stop', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': 'Dummy_Appliance_3-stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Stop', + }), + 'context': , + 'entity_id': 'button.washing_machine_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/miele/snapshots/test_climate.ambr b/tests/components/miele/snapshots/test_climate.ambr new file mode 100644 index 00000000000..85f7bf212f5 --- /dev/null +++ b/tests/components/miele/snapshots/test_climate.ambr @@ -0,0 +1,127 @@ +# serializer version: 1 +# name: test_climate_states[platforms0-freezer][climate.freezer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.freezer', + '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': 'miele', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'freezer', + 'unique_id': 'Dummy_Appliance_1-thermostat-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states[platforms0-freezer][climate.freezer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': -18, + 'friendly_name': 'Freezer', + 'hvac_modes': list([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': -18, + }), + 'context': , + 'entity_id': 'climate.freezer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_states[platforms0-freezer][climate.refrigerator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.refrigerator', + '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': 'miele', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'refrigerator', + 'unique_id': 'Dummy_Appliance_2-thermostat-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states[platforms0-freezer][climate.refrigerator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 4, + 'friendly_name': 'Refrigerator', + 'hvac_modes': list([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 4, + }), + 'context': , + 'entity_id': 'climate.refrigerator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- diff --git a/tests/components/miele/snapshots/test_diagnostics.ambr b/tests/components/miele/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..aa564205867 --- /dev/null +++ b/tests/components/miele/snapshots/test_diagnostics.ambr @@ -0,0 +1,829 @@ +# serializer version: 1 +# name: test_diagnostics_config_entry + dict({ + 'config_entry_data': dict({ + 'auth_implementation': 'miele', + 'token': dict({ + 'access_token': '**REDACTED**', + 'expires_in': 86399, + 'refresh_token': '**REDACTED**', + 'token_type': 'Bearer', + }), + }), + 'miele_data': dict({ + 'actions': dict({ + '**REDACTED_019aa577ad1c330d': dict({ + 'ambientLight': list([ + ]), + 'colors': list([ + ]), + 'deviceName': True, + 'light': list([ + ]), + 'modes': list([ + ]), + 'powerOff': False, + 'powerOn': True, + 'processAction': list([ + 1, + 2, + 3, + ]), + 'programId': list([ + ]), + 'runOnTime': list([ + ]), + 'startTime': list([ + ]), + 'targetTemperature': list([ + ]), + 'ventilationStep': list([ + ]), + }), + '**REDACTED_4b870e84d3e80013': dict({ + 'ambientLight': list([ + ]), + 'colors': list([ + ]), + 'deviceName': True, + 'light': list([ + ]), + 'modes': list([ + ]), + 'powerOff': False, + 'powerOn': True, + 'processAction': list([ + 1, + 2, + 3, + ]), + 'programId': list([ + ]), + 'runOnTime': list([ + ]), + 'startTime': list([ + ]), + 'targetTemperature': list([ + ]), + 'ventilationStep': list([ + ]), + }), + '**REDACTED_57d53e72806e88b4': dict({ + 'ambientLight': list([ + ]), + 'colors': list([ + ]), + 'deviceName': True, + 'light': list([ + ]), + 'modes': list([ + ]), + 'powerOff': False, + 'powerOn': True, + 'processAction': list([ + 1, + 2, + 3, + ]), + 'programId': list([ + ]), + 'runOnTime': list([ + ]), + 'startTime': list([ + ]), + 'targetTemperature': list([ + ]), + 'ventilationStep': list([ + ]), + }), + '**REDACTED_c9fe55cdf70786ca': dict({ + 'ambientLight': list([ + ]), + 'colors': list([ + ]), + 'deviceName': True, + 'light': list([ + ]), + 'modes': list([ + ]), + 'powerOff': False, + 'powerOn': True, + 'processAction': list([ + 1, + 2, + 3, + ]), + 'programId': list([ + ]), + 'runOnTime': list([ + ]), + 'startTime': list([ + ]), + 'targetTemperature': list([ + ]), + 'ventilationStep': list([ + ]), + }), + }), + 'devices': dict({ + '**REDACTED_019aa577ad1c330d': dict({ + 'ident': dict({ + 'deviceIdentLabel': dict({ + 'fabIndex': '17', + 'fabNumber': '**REDACTED**', + 'matNumber': '10804770', + 'swids': list([ + '4497', + ]), + 'techType': 'KS 28423 D ed/c', + }), + 'deviceName': '', + 'protocolVersion': 201, + 'type': dict({ + 'key_localized': 'Device type', + 'value_localized': 'Refrigerator', + 'value_raw': 19, + }), + 'xkmIdentLabel': dict({ + 'releaseVersion': '31.17', + 'techType': 'EK042', + }), + }), + 'state': dict({ + 'ProgramID': dict({ + 'key_localized': 'Program name', + 'value_localized': '', + 'value_raw': 0, + }), + 'ambientLight': None, + 'batteryLevel': None, + 'coreTargetTemperature': list([ + ]), + 'coreTemperature': list([ + ]), + 'dryingStep': dict({ + 'key_localized': 'Drying level', + 'value_localized': '', + 'value_raw': None, + }), + 'ecoFeedback': None, + 'elapsedTime': list([ + ]), + 'light': None, + 'plateStep': list([ + ]), + 'programPhase': dict({ + 'key_localized': 'Program phase', + 'value_localized': '', + 'value_raw': 0, + }), + 'programType': dict({ + 'key_localized': 'Program type', + 'value_localized': '', + 'value_raw': 0, + }), + 'remainingTime': list([ + 0, + 0, + ]), + 'remoteEnable': dict({ + 'fullRemoteControl': True, + 'mobileStart': False, + 'smartGrid': False, + }), + 'signalDoor': False, + 'signalFailure': False, + 'signalInfo': False, + 'spinningSpeed': dict({ + 'key_localized': 'Spin speed', + 'unit': 'rpm', + 'value_localized': None, + 'value_raw': None, + }), + 'startTime': list([ + 0, + 0, + ]), + 'status': dict({ + 'key_localized': 'status', + 'value_localized': 'In use', + 'value_raw': 5, + }), + 'targetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': 4, + 'value_raw': 400, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'temperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': 4, + 'value_raw': 400, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'ventilationStep': dict({ + 'key_localized': 'Fan level', + 'value_localized': '', + 'value_raw': None, + }), + }), + }), + '**REDACTED_4b870e84d3e80013': dict({ + 'ident': dict({ + 'deviceIdentLabel': dict({ + 'fabIndex': '64', + 'fabNumber': '**REDACTED**', + 'matNumber': '', + 'swids': list([ + '', + '', + '', + '<...>', + ]), + 'techType': 'Fläkt', + }), + 'deviceName': '', + 'protocolVersion': 2, + 'type': dict({ + 'key_localized': 'Device type', + 'value_localized': 'Cooker Hood', + 'value_raw': 18, + }), + 'xkmIdentLabel': dict({ + 'releaseVersion': '02.72', + 'techType': 'EK039W', + }), + }), + 'state': dict({ + 'ProgramID': dict({ + 'key_localized': 'Program name', + 'value_localized': 'Off', + 'value_raw': 1, + }), + 'ambientLight': 2, + 'batteryLevel': None, + 'dryingStep': dict({ + 'key_localized': 'Drying level', + 'value_localized': '', + 'value_raw': None, + }), + 'ecoFeedback': None, + 'elapsedTime': dict({ + }), + 'light': 1, + 'plateStep': list([ + ]), + 'programPhase': dict({ + 'key_localized': 'Program phase', + 'value_localized': '', + 'value_raw': 4608, + }), + 'programType': dict({ + 'key_localized': 'Program type', + 'value_localized': 'Program', + 'value_raw': 0, + }), + 'remainingTime': list([ + 0, + 0, + ]), + 'remoteEnable': dict({ + 'fullRemoteControl': True, + 'mobileStart': False, + 'smartGrid': False, + }), + 'signalDoor': False, + 'signalFailure': False, + 'signalInfo': False, + 'spinningSpeed': dict({ + 'key_localized': 'Spin speed', + 'unit': 'rpm', + 'value_localized': None, + 'value_raw': None, + }), + 'startTime': list([ + 0, + 0, + ]), + 'status': dict({ + 'key_localized': 'status', + 'value_localized': 'Off', + 'value_raw': 1, + }), + 'targetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'temperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'ventilationStep': dict({ + 'key_localized': 'Fan level', + 'value_localized': '0', + 'value_raw': 0, + }), + }), + }), + '**REDACTED_57d53e72806e88b4': dict({ + 'ident': dict({ + 'deviceIdentLabel': dict({ + 'fabIndex': '21', + 'fabNumber': '**REDACTED**', + 'matNumber': '10805070', + 'swids': list([ + '4497', + ]), + 'techType': 'FNS 28463 E ed/', + }), + 'deviceName': '', + 'protocolVersion': 201, + 'type': dict({ + 'key_localized': 'Device type', + 'value_localized': 'Freezer', + 'value_raw': 20, + }), + 'xkmIdentLabel': dict({ + 'releaseVersion': '31.17', + 'techType': 'EK042', + }), + }), + 'state': dict({ + 'ProgramID': dict({ + 'key_localized': 'Program name', + 'value_localized': '', + 'value_raw': 0, + }), + 'ambientLight': None, + 'batteryLevel': None, + 'coreTargetTemperature': list([ + ]), + 'coreTemperature': list([ + ]), + 'dryingStep': dict({ + 'key_localized': 'Drying level', + 'value_localized': '', + 'value_raw': None, + }), + 'ecoFeedback': None, + 'elapsedTime': list([ + ]), + 'light': None, + 'plateStep': list([ + ]), + 'programPhase': dict({ + 'key_localized': 'Program phase', + 'value_localized': '', + 'value_raw': 0, + }), + 'programType': dict({ + 'key_localized': 'Program type', + 'value_localized': '', + 'value_raw': 0, + }), + 'remainingTime': list([ + 0, + 0, + ]), + 'remoteEnable': dict({ + 'fullRemoteControl': True, + 'mobileStart': False, + 'smartGrid': False, + }), + 'signalDoor': False, + 'signalFailure': False, + 'signalInfo': False, + 'spinningSpeed': dict({ + 'key_localized': 'Spin speed', + 'unit': 'rpm', + 'value_localized': None, + 'value_raw': None, + }), + 'startTime': list([ + 0, + 0, + ]), + 'status': dict({ + 'key_localized': 'status', + 'value_localized': 'In use', + 'value_raw': 5, + }), + 'targetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': -18, + 'value_raw': -1800, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'temperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': -18, + 'value_raw': -1800, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'ventilationStep': dict({ + 'key_localized': 'Fan level', + 'value_localized': '', + 'value_raw': None, + }), + }), + }), + '**REDACTED_c9fe55cdf70786ca': dict({ + 'ident': dict({ + 'deviceIdentLabel': dict({ + 'fabIndex': '44', + 'fabNumber': '**REDACTED**', + 'matNumber': '11387290', + 'swids': list([ + '5975', + '20456', + '25213', + '25191', + '25446', + '25205', + '25447', + '25319', + ]), + 'techType': 'WCI870', + }), + 'deviceName': '', + 'protocolVersion': 4, + 'type': dict({ + 'key_localized': 'Device type', + 'value_localized': 'Washing machine', + 'value_raw': 1, + }), + 'xkmIdentLabel': dict({ + 'releaseVersion': '08.32', + 'techType': 'EK057', + }), + }), + 'state': dict({ + 'ProgramID': dict({ + 'key_localized': 'Program name', + 'value_localized': '', + 'value_raw': 0, + }), + 'ambientLight': None, + 'batteryLevel': None, + 'coreTargetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'coreTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'dryingStep': dict({ + 'key_localized': 'Drying level', + 'value_localized': '', + 'value_raw': None, + }), + 'ecoFeedback': dict({ + 'currentEnergyConsumption': dict({ + 'unit': 'kWh', + 'value': 0.0, + }), + 'currentWaterConsumption': dict({ + 'unit': 'l', + 'value': 0.0, + }), + 'energyForecast': 0.1, + 'waterForecast': 0.0, + }), + 'elapsedTime': list([ + 0, + 0, + ]), + 'light': None, + 'plateStep': list([ + ]), + 'programPhase': dict({ + 'key_localized': 'Program phase', + 'value_localized': '', + 'value_raw': 0, + }), + 'programType': dict({ + 'key_localized': 'Program type', + 'value_localized': '', + 'value_raw': 0, + }), + 'remainingTime': list([ + 0, + 0, + ]), + 'remoteEnable': dict({ + 'fullRemoteControl': True, + 'mobileStart': False, + 'smartGrid': False, + }), + 'signalDoor': True, + 'signalFailure': False, + 'signalInfo': False, + 'spinningSpeed': dict({ + 'key_localized': 'Spin speed', + 'unit': 'rpm', + 'value_localized': None, + 'value_raw': None, + }), + 'startTime': list([ + 0, + 0, + ]), + 'status': dict({ + 'key_localized': 'status', + 'value_localized': 'Off', + 'value_raw': 1, + }), + 'targetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'temperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'ventilationStep': dict({ + 'key_localized': 'Fan level', + 'value_localized': '', + 'value_raw': None, + }), + }), + }), + }), + 'missing_code_warnings': list([ + 'None', + ]), + }), + }) +# --- +# name: test_diagnostics_device + dict({ + 'data': dict({ + 'auth_implementation': 'miele', + 'token': dict({ + 'access_token': '**REDACTED**', + 'expires_in': 86399, + 'refresh_token': '**REDACTED**', + 'token_type': 'Bearer', + }), + }), + 'info': dict({ + 'manufacturer': 'Miele', + 'model': 'FNS 28463 E ed/', + }), + 'miele_data': dict({ + 'actions': dict({ + '**REDACTED_57d53e72806e88b4': dict({ + 'ambientLight': list([ + ]), + 'colors': list([ + ]), + 'deviceName': True, + 'light': list([ + ]), + 'modes': list([ + ]), + 'powerOff': False, + 'powerOn': True, + 'processAction': list([ + 1, + 2, + 3, + ]), + 'programId': list([ + ]), + 'runOnTime': list([ + ]), + 'startTime': list([ + ]), + 'targetTemperature': list([ + ]), + 'ventilationStep': list([ + ]), + }), + }), + 'devices': dict({ + '**REDACTED_57d53e72806e88b4': dict({ + 'ident': dict({ + 'deviceIdentLabel': dict({ + 'fabIndex': '21', + 'fabNumber': '**REDACTED**', + 'matNumber': '10805070', + 'swids': list([ + '4497', + ]), + 'techType': 'FNS 28463 E ed/', + }), + 'deviceName': '', + 'protocolVersion': 201, + 'type': dict({ + 'key_localized': 'Device type', + 'value_localized': 'Freezer', + 'value_raw': 20, + }), + 'xkmIdentLabel': dict({ + 'releaseVersion': '31.17', + 'techType': 'EK042', + }), + }), + 'state': dict({ + 'ProgramID': dict({ + 'key_localized': 'Program name', + 'value_localized': '', + 'value_raw': 0, + }), + 'ambientLight': None, + 'batteryLevel': None, + 'coreTargetTemperature': list([ + ]), + 'coreTemperature': list([ + ]), + 'dryingStep': dict({ + 'key_localized': 'Drying level', + 'value_localized': '', + 'value_raw': None, + }), + 'ecoFeedback': None, + 'elapsedTime': list([ + ]), + 'light': None, + 'plateStep': list([ + ]), + 'programPhase': dict({ + 'key_localized': 'Program phase', + 'value_localized': '', + 'value_raw': 0, + }), + 'programType': dict({ + 'key_localized': 'Program type', + 'value_localized': '', + 'value_raw': 0, + }), + 'remainingTime': list([ + 0, + 0, + ]), + 'remoteEnable': dict({ + 'fullRemoteControl': True, + 'mobileStart': False, + 'smartGrid': False, + }), + 'signalDoor': False, + 'signalFailure': False, + 'signalInfo': False, + 'spinningSpeed': dict({ + 'key_localized': 'Spin speed', + 'unit': 'rpm', + 'value_localized': None, + 'value_raw': None, + }), + 'startTime': list([ + 0, + 0, + ]), + 'status': dict({ + 'key_localized': 'status', + 'value_localized': 'In use', + 'value_raw': 5, + }), + 'targetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': -18, + 'value_raw': -1800, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'temperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': -18, + 'value_raw': -1800, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'ventilationStep': dict({ + 'key_localized': 'Fan level', + 'value_localized': '', + 'value_raw': None, + }), + }), + }), + }), + 'missing_code_warnings': list([ + 'None', + ]), + 'programs': 'Not implemented', + }), + }) +# --- diff --git a/tests/components/miele/snapshots/test_fan.ambr b/tests/components/miele/snapshots/test_fan.ambr new file mode 100644 index 00000000000..595d4463462 --- /dev/null +++ b/tests/components/miele/snapshots/test_fan.ambr @@ -0,0 +1,153 @@ +# serializer version: 1 +# name: test_fan_states[fan_devices.json-platforms0][fan.hob_with_extraction_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.hob_with_extraction_fan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan', + 'unique_id': 'DummyAppliance_74-fan_readonly', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_states[fan_devices.json-platforms0][fan.hob_with_extraction_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hob with extraction Fan', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.hob_with_extraction_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_fan_states[fan_devices.json-platforms0][fan.hob_with_extraction_fan_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.hob_with_extraction_fan_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': 'Fan', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan', + 'unique_id': 'DummyAppliance_74_off-fan_readonly', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_states[fan_devices.json-platforms0][fan.hob_with_extraction_fan_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hob with extraction Fan', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.hob_with_extraction_fan_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_fan_states[fan_devices.json-platforms0][fan.hood_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.hood_fan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'fan', + 'unique_id': 'DummyAppliance_18-fan', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_states[fan_devices.json-platforms0][fan.hood_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hood Fan', + 'percentage': 0, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.hood_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/miele/snapshots/test_init.ambr b/tests/components/miele/snapshots/test_init.ambr new file mode 100644 index 00000000000..eee976ab09f --- /dev/null +++ b/tests/components/miele/snapshots/test_init.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'EK042', + 'id': , + 'identifiers': set({ + tuple( + 'miele', + 'Dummy_Appliance_1', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Miele', + 'model': 'FNS 28463 E ed/', + 'model_id': None, + 'name': 'Freezer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'Dummy_Appliance_1', + 'suggested_area': None, + 'sw_version': '31.17', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/miele/snapshots/test_light.ambr b/tests/components/miele/snapshots/test_light.ambr new file mode 100644 index 00000000000..128b642d7a0 --- /dev/null +++ b/tests/components/miele/snapshots/test_light.ambr @@ -0,0 +1,113 @@ +# serializer version: 1 +# name: test_light_states[platforms0][light.hood_ambient_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hood_ambient_light', + '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': 'Ambient light', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ambient_light', + 'unique_id': 'DummyAppliance_18-ambient_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_states[platforms0][light.hood_ambient_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Hood Ambient light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.hood_ambient_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_light_states[platforms0][light.hood_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hood_light', + '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', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'DummyAppliance_18-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_states[platforms0][light.hood_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Hood Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.hood_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..9cc2aa83b01 --- /dev/null +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -0,0 +1,1045 @@ +# serializer version: 1 +# name: test_sensor_states[platforms0][sensor.freezer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.freezer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fridge-industrial-outline', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'Dummy_Appliance_1-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.freezer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Freezer', + 'icon': 'mdi:fridge-industrial-outline', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.freezer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_sensor_states[platforms0][sensor.freezer_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.freezer_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': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_1-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.freezer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Freezer Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.freezer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-18.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.hood-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hood', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:turbine', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_18-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.hood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hood', + 'icon': 'mdi:turbine', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.hood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_states[platforms0][sensor.refrigerator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fridge-industrial-outline', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'Dummy_Appliance_2-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.refrigerator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Refrigerator', + 'icon': 'mdi:fridge-industrial-outline', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.refrigerator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_sensor_states[platforms0][sensor.refrigerator_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_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': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_2-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.refrigerator_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:washing-machine', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'Dummy_Appliance_3-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washing machine', + 'icon': 'mdi:washing-machine', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.washing_machine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_elapsed_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_elapsed_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Elapsed time', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'elapsed_time', + 'unique_id': 'Dummy_Appliance_3-state_elapsed_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_elapsed_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Washing machine Elapsed time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_elapsed_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_energy_consumption', + '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 consumption', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumption', + 'unique_id': 'Dummy_Appliance_3-current_energy_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing machine Energy consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'automatic_plus', + 'clean_machine', + 'cool_air', + 'cottons', + 'cottons_eco', + 'cottons_hygiene', + 'curtains', + 'dark_garments', + 'delicates', + 'denim', + 'down_duvets', + 'down_filled_items', + 'drain_spin', + 'eco_40_60', + 'express_20', + 'first_wash', + 'freshen_up', + 'minimum_iron', + 'no_program', + 'outerwear', + 'pillows', + 'proofing', + 'quick_power_wash', + 'rinse', + 'rinse_out_lint', + 'separate_rinse_starch', + 'shirts', + 'silks', + 'sportswear', + 'starch', + 'steam_care', + 'trainers', + 'warm_air', + 'woollens', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'program_id', + 'unique_id': 'Dummy_Appliance_3-state_program_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washing machine Program', + 'options': list([ + 'automatic_plus', + 'clean_machine', + 'cool_air', + 'cottons', + 'cottons_eco', + 'cottons_hygiene', + 'curtains', + 'dark_garments', + 'delicates', + 'denim', + 'down_duvets', + 'down_filled_items', + 'drain_spin', + 'eco_40_60', + 'express_20', + 'first_wash', + 'freshen_up', + 'minimum_iron', + 'no_program', + 'outerwear', + 'pillows', + 'proofing', + 'quick_power_wash', + 'rinse', + 'rinse_out_lint', + 'separate_rinse_starch', + 'shirts', + 'silks', + 'sportswear', + 'starch', + 'steam_care', + 'trainers', + 'warm_air', + 'woollens', + ]), + }), + 'context': , + 'entity_id': 'sensor.washing_machine_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_program', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_program_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'anti_crease', + 'cleaning', + 'cooling_down', + 'disinfecting', + 'drain', + 'drying', + 'finished', + 'freshen_up_and_moisten', + 'hygiene', + 'main_wash', + 'not_running', + 'pre_wash', + 'rinse', + 'rinse_hold', + 'soak', + 'spin', + 'starch_stop', + 'steam_smoothing', + 'venting', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_program_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program phase', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'program_phase', + 'unique_id': 'Dummy_Appliance_3-state_program_phase', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_program_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washing machine Program phase', + 'options': list([ + 'anti_crease', + 'cleaning', + 'cooling_down', + 'disinfecting', + 'drain', + 'drying', + 'finished', + 'freshen_up_and_moisten', + 'hygiene', + 'main_wash', + 'not_running', + 'pre_wash', + 'rinse', + 'rinse_hold', + 'soak', + 'spin', + 'starch_stop', + 'steam_smoothing', + 'venting', + ]), + }), + 'context': , + 'entity_id': 'sensor.washing_machine_program_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_running', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_program_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_program_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program type', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'program_type', + 'unique_id': 'Dummy_Appliance_3-state_program_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_program_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washing machine Program type', + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'context': , + 'entity_id': 'sensor.washing_machine_program_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal_operation_mode', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining time', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'Dummy_Appliance_3-state_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Washing machine Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_spin_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_spin_speed', + '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': 'Spin speed', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'spin_speed', + 'unique_id': 'Dummy_Appliance_3-state_spinning_speed', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_spin_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Spin speed', + 'unit_of_measurement': 'rpm', + }), + 'context': , + 'entity_id': 'sensor.washing_machine_spin_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_start_in-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_start_in', + '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': 'Start in', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_time', + 'unique_id': 'Dummy_Appliance_3-state_start_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_start_in-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Washing machine Start in', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_start_in', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_water_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_water_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water consumption', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_consumption', + 'unique_id': 'Dummy_Appliance_3-current_water_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_water_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Washing machine Water consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_water_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/miele/snapshots/test_switch.ambr b/tests/components/miele/snapshots/test_switch.ambr new file mode 100644 index 00000000000..b7f49f84eed --- /dev/null +++ b/tests/components/miele/snapshots/test_switch.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_switch_states[platforms0][switch.freezer_superfreezing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.freezer_superfreezing', + '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': 'Superfreezing', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'superfreezing', + 'unique_id': 'Dummy_Appliance_1-superfreezing', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.freezer_superfreezing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Freezer Superfreezing', + }), + 'context': , + 'entity_id': 'switch.freezer_superfreezing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_states[platforms0][switch.hood_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.hood_power', + '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', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'DummyAppliance_18-poweronoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.hood_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hood Power', + }), + 'context': , + 'entity_id': 'switch.hood_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_states[platforms0][switch.refrigerator_supercooling-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.refrigerator_supercooling', + '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': 'Supercooling', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supercooling', + 'unique_id': 'Dummy_Appliance_2-supercooling', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.refrigerator_supercooling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Supercooling', + }), + 'context': , + 'entity_id': 'switch.refrigerator_supercooling', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_states[platforms0][switch.washing_machine_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.washing_machine_power', + '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', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'Dummy_Appliance_3-poweronoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.washing_machine_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Power', + }), + 'context': , + 'entity_id': 'switch.washing_machine_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/miele/test_binary_sensor.py b/tests/components/miele/test_binary_sensor.py new file mode 100644 index 00000000000..fe1f4b896c5 --- /dev/null +++ b/tests/components/miele/test_binary_sensor.py @@ -0,0 +1,27 @@ +"""Tests for miele binary sensor module.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("platforms", [(BINARY_SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test binary sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/miele/test_button.py b/tests/components/miele/test_button.py new file mode 100644 index 00000000000..9bf5f2f3f54 --- /dev/null +++ b/tests/components/miele/test_button.py @@ -0,0 +1,66 @@ +"""Tests for Miele button module.""" + +from unittest.mock import MagicMock + +from aiohttp import ClientResponseError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +TEST_PLATFORM = BUTTON_DOMAIN +pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) + +ENTITY_ID = "button.washing_machine_start" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test button entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button_press( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, +) -> None: + """Test button press.""" + + await hass.services.async_call( + TEST_PLATFORM, SERVICE_PRESS, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + mock_miele_client.send_action.assert_called_once_with( + "Dummy_Appliance_3", {"processAction": 1} + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_api_failure( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, +) -> None: + """Test handling of exception from API.""" + mock_miele_client.send_action.side_effect = ClientResponseError("test", "Test") + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TEST_PLATFORM, SERVICE_PRESS, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + mock_miele_client.send_action.assert_called_once() diff --git a/tests/components/miele/test_climate.py b/tests/components/miele/test_climate.py new file mode 100644 index 00000000000..f03edada841 --- /dev/null +++ b/tests/components/miele/test_climate.py @@ -0,0 +1,79 @@ +"""Tests for miele climate module.""" + +from unittest.mock import MagicMock + +from aiohttp import ClientError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +TEST_PLATFORM = CLIMATE_DOMAIN +pytestmark = [ + pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]), + pytest.mark.parametrize( + "load_action_file", + ["action_freezer.json"], + ids=[ + "freezer", + ], + ), +] + +ENTITY_ID = "climate.freezer" +SERVICE_SET_TEMPERATURE = "set_temperature" + + +async def test_climate_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test climate entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_target( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, +) -> None: + """Test the climate can be turned on/off.""" + + await hass.services.async_call( + TEST_PLATFORM, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: -17}, + blocking=True, + ) + mock_miele_client.set_target_temperature.assert_called_once_with( + "Dummy_Appliance_1", -17.0, 1 + ) + + +async def test_api_failure( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, +) -> None: + """Test handling of exception from API.""" + mock_miele_client.set_target_temperature.side_effect = ClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TEST_PLATFORM, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: -17}, + blocking=True, + ) + mock_miele_client.set_target_temperature.assert_called_once() diff --git a/tests/components/miele/test_config_flow.py b/tests/components/miele/test_config_flow.py new file mode 100644 index 00000000000..78478bc0e9d --- /dev/null +++ b/tests/components/miele/test_config_flow.py @@ -0,0 +1,266 @@ +"""Test the Miele config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock + +from pymiele import OAUTH2_AUTHORIZE, OAUTH2_TOKEN +import pytest + +from homeassistant.components.miele.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 .const import CLIENT_ID + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +REDIRECT_URL = "https://example.com/auth/external/callback" + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: Generator[AsyncMock], +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&vg=sv-SE" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result.get("type") is FlowResultType.EXTERNAL_STEP + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_reauth_abort( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, + access_token: str, + expires_at: float, +) -> None: + """Test reauth step with correct params.""" + + CURRENT_TOKEN = { + "auth_implementation": DOMAIN, + "token": { + "access_token": access_token, + "expires_in": 86399, + "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", + "token_type": "Bearer", + "expires_at": expires_at, + }, + } + assert hass.config_entries.async_update_entry( + mock_config_entry, + data=CURRENT_TOKEN, + ) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["step_id"] == "auth" + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&vg=sv-SE" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "updated-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": "60", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_reconfigure_abort( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, + access_token: str, + expires_at: float, +) -> None: + """Test reconfigure step with correct params.""" + + CURRENT_TOKEN = { + "auth_implementation": DOMAIN, + "token": { + "access_token": access_token, + "expires_in": 86399, + "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", + "token_type": "Bearer", + "expires_at": expires_at, + }, + } + assert hass.config_entries.async_update_entry( + mock_config_entry, + data=CURRENT_TOKEN, + ) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["step_id"] == "auth" + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&vg=sv-SE" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "updated-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": "60", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reconfigure_successful" + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_zeroconf_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: Generator[AsyncMock], +) -> None: + """Test zeroconf flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&vg=sv-SE" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + assert result.get("type") is FlowResultType.EXTERNAL_STEP + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.domain == "miele" diff --git a/tests/components/miele/test_diagnostics.py b/tests/components/miele/test_diagnostics.py new file mode 100644 index 00000000000..cf322b971c8 --- /dev/null +++ b/tests/components/miele/test_diagnostics.py @@ -0,0 +1,69 @@ +"""Tests for the diagnostics data provided by the miele integration.""" + +from collections.abc import Generator +from unittest.mock import MagicMock + +from syrupy import SnapshotAssertion +from syrupy.filters import paths + +from homeassistant.components.miele.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics_config_entry( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_miele_client: Generator[MagicMock], + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + + await setup_integration(hass, mock_config_entry) + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot( + exclude=paths( + "config_entry_data.token.expires_at", + "miele_test.entry_id", + ) + ) + + +async def test_diagnostics_device( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: DeviceRegistry, + mock_miele_client: Generator[MagicMock], + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for device.""" + + TEST_DEVICE = "Dummy_Appliance_1" + + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, TEST_DEVICE)}) + assert device_entry is not None + + result = await get_diagnostics_for_device( + hass, hass_client, mock_config_entry, device_entry + ) + assert result == snapshot( + exclude=paths( + "data.token.expires_at", + "miele_test.entry_id", + ) + ) diff --git a/tests/components/miele/test_fan.py b/tests/components/miele/test_fan.py new file mode 100644 index 00000000000..87f80614551 --- /dev/null +++ b/tests/components/miele/test_fan.py @@ -0,0 +1,115 @@ +"""Tests for miele fan module.""" + +from typing import Any +from unittest.mock import MagicMock + +from aiohttp import ClientResponseError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.fan import ATTR_PERCENTAGE, DOMAIN as FAN_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +TEST_PLATFORM = FAN_DOMAIN +pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) + +ENTITY_ID = "fan.hood_fan" + + +@pytest.mark.parametrize("load_device_file", ["fan_devices.json"]) +async def test_fan_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test fan entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize("load_device_file", ["fan_devices.json"]) +@pytest.mark.parametrize( + ("service", "expected_argument"), + [ + (SERVICE_TURN_ON, {"powerOn": True}), + (SERVICE_TURN_OFF, {"powerOff": True}), + ], +) +async def test_fan_control( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + service: str, + expected_argument: dict[str, Any], +) -> None: + """Test the fan can be turned on/off.""" + + await hass.services.async_call( + TEST_PLATFORM, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_miele_client.send_action.assert_called_once_with( + "DummyAppliance_18", expected_argument + ) + + +@pytest.mark.parametrize( + ("service", "percentage", "expected_argument"), + [ + ("set_percentage", 0, {"powerOff": True}), + ("set_percentage", 20, {"ventilationStep": 1}), + ("set_percentage", 100, {"ventilationStep": 4}), + ], +) +async def test_fan_set_speed( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + service: str, + percentage: int, + expected_argument: dict[str, Any], +) -> None: + """Test the fan can be turned on/off.""" + + await hass.services.async_call( + TEST_PLATFORM, + service, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: percentage}, + blocking=True, + ) + mock_miele_client.send_action.assert_called_once_with( + "DummyAppliance_18", expected_argument + ) + + +@pytest.mark.parametrize( + ("service"), + [ + (SERVICE_TURN_ON), + (SERVICE_TURN_OFF), + ], +) +async def test_api_failure( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + service: str, +) -> None: + """Test handling of exception from API.""" + mock_miele_client.send_action.side_effect = ClientResponseError("test", "Test") + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TEST_PLATFORM, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + mock_miele_client.send_action.assert_called_once() diff --git a/tests/components/miele/test_init.py b/tests/components/miele/test_init.py new file mode 100644 index 00000000000..7a81ef78065 --- /dev/null +++ b/tests/components/miele/test_init.py @@ -0,0 +1,159 @@ +"""Tests for init module.""" + +import http +import time +from unittest.mock import MagicMock + +from aiohttp import ClientConnectionError +from pymiele import OAUTH2_TOKEN +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.miele.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import WebSocketGenerator + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + entry = mock_config_entry + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("expires_at", "status", "expected_state"), + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["unauthorized", "internal_server_error"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + status=status, + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_state + + +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_connection_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test failure while refreshing token with a ClientError.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + exc=ClientConnectionError(), + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_devices_multiple_created_count( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that multiple devices are created.""" + await setup_integration(hass, mock_config_entry) + + assert len(device_registry.devices) == 4 + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "Dummy_Appliance_1")} + ) + assert device_entry is not None + assert device_entry == snapshot + + +async def test_device_remove_devices( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, + mock_miele_client: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test we can only remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_entry = device_registry.async_get_device( + identifiers={ + ( + DOMAIN, + "Dummy_Appliance_1", + ) + }, + ) + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, mock_config_entry.entry_id) + assert not response["success"] + + old_device_entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "OLD-DEVICE-UUID")}, + ) + response = await client.remove_device( + old_device_entry.id, mock_config_entry.entry_id + ) + assert response["success"] diff --git a/tests/components/miele/test_light.py b/tests/components/miele/test_light.py new file mode 100644 index 00000000000..286c2df0dd8 --- /dev/null +++ b/tests/components/miele/test_light.py @@ -0,0 +1,80 @@ +"""Tests for miele light module.""" + +from unittest.mock import MagicMock + +from aiohttp import ClientError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +TEST_PLATFORM = LIGHT_DOMAIN +pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) + +ENTITY_ID = "light.hood_light" + + +async def test_light_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test light entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("service", "light_state"), + [ + (SERVICE_TURN_ON, 1), + (SERVICE_TURN_OFF, 2), + ], +) +async def test_light_toggle( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + service: str, + light_state: int, +) -> None: + """Test the light can be turned on/off.""" + + await hass.services.async_call( + TEST_PLATFORM, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + mock_miele_client.send_action.assert_called_once_with( + "DummyAppliance_18", {"light": light_state} + ) + + +@pytest.mark.parametrize( + ("service"), + [ + (SERVICE_TURN_ON), + (SERVICE_TURN_OFF), + ], +) +async def test_api_failure( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + service: str, +) -> None: + """Test handling of exception from API.""" + mock_miele_client.send_action.side_effect = ClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TEST_PLATFORM, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + mock_miele_client.send_action.assert_called_once() diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py new file mode 100644 index 00000000000..0a12a9e85e4 --- /dev/null +++ b/tests/components/miele/test_sensor.py @@ -0,0 +1,27 @@ +"""Tests for miele sensor module.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/miele/test_switch.py b/tests/components/miele/test_switch.py new file mode 100644 index 00000000000..fa5e9360da6 --- /dev/null +++ b/tests/components/miele/test_switch.py @@ -0,0 +1,95 @@ +"""Tests for miele switch module.""" + +from unittest.mock import MagicMock + +from aiohttp import ClientError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +TEST_PLATFORM = SWITCH_DOMAIN +pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) + +ENTITY_ID = "switch.freezer_superfreezing" + + +async def test_switch_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test switch entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity"), + [ + (ENTITY_ID), + ("switch.refrigerator_supercooling"), + ("switch.washing_machine_power"), + ], +) +@pytest.mark.parametrize( + ("service"), + [ + (SERVICE_TURN_ON), + (SERVICE_TURN_OFF), + ], +) +async def test_switching( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + service: str, + entity: str, +) -> None: + """Test the switch can be turned on/off.""" + + await hass.services.async_call( + TEST_PLATFORM, service, {ATTR_ENTITY_ID: entity}, blocking=True + ) + mock_miele_client.send_action.assert_called_once() + + +@pytest.mark.parametrize( + ("entity"), + [ + (ENTITY_ID), + ("switch.refrigerator_supercooling"), + ("switch.washing_machine_power"), + ], +) +@pytest.mark.parametrize( + ("service"), + [ + (SERVICE_TURN_ON), + (SERVICE_TURN_OFF), + ], +) +async def test_api_failure( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + service: str, + entity: str, +) -> None: + """Test handling of exception from API.""" + mock_miele_client.send_action.side_effect = ClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TEST_PLATFORM, service, {ATTR_ENTITY_ID: entity}, blocking=True + ) + mock_miele_client.send_action.assert_called_once() diff --git a/tests/components/mill/conftest.py b/tests/components/mill/conftest.py new file mode 100644 index 00000000000..28b2e58057b --- /dev/null +++ b/tests/components/mill/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the mill tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.mill.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/mill/test_config_flow.py b/tests/components/mill/test_config_flow.py index 832aaef3b19..2bff9ba15e1 100644 --- a/tests/components/mill/test_config_flow.py +++ b/tests/components/mill/test_config_flow.py @@ -1,17 +1,24 @@ """Tests for Mill config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +import pytest from homeassistant import config_entries from homeassistant.components.mill.const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL +from homeassistant.components.recorder import Recorder from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_show_config_form(hass: HomeAssistant) -> None: + +async def test_show_config_form( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test show configuration form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -21,7 +28,9 @@ async def test_show_config_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_create_entry(hass: HomeAssistant) -> None: +async def test_create_entry( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test create entry from user input.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -56,7 +65,9 @@ async def test_create_entry(hass: HomeAssistant) -> None: } -async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: +async def test_flow_entry_already_exists( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test user input for config_entry that already exists.""" test_data = { @@ -96,7 +107,9 @@ async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_connection_error(hass: HomeAssistant) -> None: +async def test_connection_error( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test connection error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -125,7 +138,9 @@ async def test_connection_error(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_local_create_entry(hass: HomeAssistant) -> None: +async def test_local_create_entry( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test create entry from user input.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -165,7 +180,9 @@ async def test_local_create_entry(hass: HomeAssistant) -> None: assert result["data"] == test_data -async def test_local_flow_entry_already_exists(hass: HomeAssistant) -> None: +async def test_local_flow_entry_already_exists( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test user input for config_entry that already exists.""" test_data = { @@ -215,7 +232,9 @@ async def test_local_flow_entry_already_exists(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_local_connection_error(hass: HomeAssistant) -> None: +async def test_local_connection_error( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test connection error.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/mill/test_coordinator.py b/tests/components/mill/test_coordinator.py new file mode 100644 index 00000000000..a2a3bd57b65 --- /dev/null +++ b/tests/components/mill/test_coordinator.py @@ -0,0 +1,225 @@ +"""Test adding external statistics from Mill.""" + +from unittest.mock import AsyncMock + +from mill import Heater, Mill, Sensor + +from homeassistant.components.mill.const import DOMAIN +from homeassistant.components.mill.coordinator import MillHistoricDataUpdateCoordinator +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.statistics import statistics_during_period +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.components.recorder.common import async_wait_recording_done + + +async def test_mill_historic_data(recorder_mock: Recorder, hass: HomeAssistant) -> None: + """Test historic data from Mill.""" + + data = { + dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): 2, + dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, + dt_util.parse_datetime("2024-12-03T02:00:00+01:00"): 4, + } + + mill_data_connection = Mill("", "", websession=AsyncMock()) + mill_data_connection.fetch_heater_and_sensor_data = AsyncMock(return_value=None) + mill_data_connection.devices = {"dev_id": Heater(name="heater_name")} + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=data) + + statistic_id = f"{DOMAIN}:energy_dev_id" + + coordinator = MillHistoricDataUpdateCoordinator( + hass, mill_data_connection=mill_data_connection + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + + assert len(stats) == 1 + assert len(stats[statistic_id]) == 3 + _sum = 0 + for stat in stats[statistic_id]: + start = dt_util.utc_from_timestamp(stat["start"]) + assert start in data + assert stat["state"] == data[start] + assert stat["last_reset"] is None + + _sum += data[start] + assert stat["sum"] == _sum + + data2 = { + dt_util.parse_datetime("2024-12-03T02:00:00+01:00"): 4.5, + dt_util.parse_datetime("2024-12-03T03:00:00+01:00"): 5, + dt_util.parse_datetime("2024-12-03T04:00:00+01:00"): 6, + dt_util.parse_datetime("2024-12-03T05:00:00+01:00"): 7, + } + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=data2) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + assert len(stats) == 1 + assert len(stats[statistic_id]) == 6 + _sum = 0 + for stat in stats[statistic_id]: + start = dt_util.utc_from_timestamp(stat["start"]) + val = data2.get(start) if start in data2 else data.get(start) + assert val is not None + assert stat["state"] == val + assert stat["last_reset"] is None + + _sum += val + assert stat["sum"] == _sum + + +async def test_mill_historic_data_no_heater( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test historic data from Mill.""" + + data = { + dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): 2, + dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, + dt_util.parse_datetime("2024-12-03T02:00:00+01:00"): 4, + } + + mill_data_connection = Mill("", "", websession=AsyncMock()) + mill_data_connection.fetch_heater_and_sensor_data = AsyncMock(return_value=None) + mill_data_connection.devices = {"dev_id": Sensor(name="sensor_name")} + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=data) + + statistic_id = f"{DOMAIN}:energy_dev_id" + + coordinator = MillHistoricDataUpdateCoordinator( + hass, mill_data_connection=mill_data_connection + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + + assert len(stats) == 0 + + +async def test_mill_historic_data_no_data( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test historic data from Mill.""" + + data = { + dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): 2, + dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, + dt_util.parse_datetime("2024-12-03T02:00:00+01:00"): 4, + } + + mill_data_connection = Mill("", "", websession=AsyncMock()) + mill_data_connection.fetch_heater_and_sensor_data = AsyncMock(return_value=None) + mill_data_connection.devices = {"dev_id": Heater(name="heater_name")} + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=data) + + coordinator = MillHistoricDataUpdateCoordinator( + hass, mill_data_connection=mill_data_connection + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + statistic_id = f"{DOMAIN}:energy_dev_id" + + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + assert len(stats) == 1 + assert len(stats[statistic_id]) == 3 + + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=None) + + coordinator = MillHistoricDataUpdateCoordinator( + hass, mill_data_connection=mill_data_connection + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + + assert len(stats) == 1 + assert len(stats[statistic_id]) == 3 + + +async def test_mill_historic_data_invalid_data( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test historic data from Mill.""" + + data = { + dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): None, + dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, + dt_util.parse_datetime("3024-12-03T02:00:00+01:00"): 4, + } + + mill_data_connection = Mill("", "", websession=AsyncMock()) + mill_data_connection.fetch_heater_and_sensor_data = AsyncMock(return_value=None) + mill_data_connection.devices = {"dev_id": Heater(name="heater_name")} + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=data) + + statistic_id = f"{DOMAIN}:energy_dev_id" + + coordinator = MillHistoricDataUpdateCoordinator( + hass, mill_data_connection=mill_data_connection + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + + assert len(stats) == 1 + assert len(stats[statistic_id]) == 1 diff --git a/tests/components/mill/test_init.py b/tests/components/mill/test_init.py index a47e6422bf8..97b40d10d18 100644 --- a/tests/components/mill/test_init.py +++ b/tests/components/mill/test_init.py @@ -4,6 +4,7 @@ import asyncio from unittest.mock import patch from homeassistant.components import mill +from homeassistant.components.recorder import Recorder from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -11,7 +12,9 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -async def test_setup_with_cloud_config(hass: HomeAssistant) -> None: +async def test_setup_with_cloud_config( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: """Test setup of cloud config.""" entry = MockConfigEntry( domain=mill.DOMAIN, @@ -31,7 +34,9 @@ async def test_setup_with_cloud_config(hass: HomeAssistant) -> None: assert len(mock_connect.mock_calls) == 1 -async def test_setup_with_cloud_config_fails(hass: HomeAssistant) -> None: +async def test_setup_with_cloud_config_fails( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: """Test setup of cloud config.""" entry = MockConfigEntry( domain=mill.DOMAIN, @@ -47,7 +52,9 @@ async def test_setup_with_cloud_config_fails(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_setup_with_cloud_config_times_out(hass: HomeAssistant) -> None: +async def test_setup_with_cloud_config_times_out( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: """Test setup of cloud config will retry if timed out.""" entry = MockConfigEntry( domain=mill.DOMAIN, @@ -63,7 +70,9 @@ async def test_setup_with_cloud_config_times_out(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_setup_with_old_cloud_config(hass: HomeAssistant) -> None: +async def test_setup_with_old_cloud_config( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: """Test setup of old cloud config.""" entry = MockConfigEntry( domain=mill.DOMAIN, @@ -82,7 +91,9 @@ async def test_setup_with_old_cloud_config(hass: HomeAssistant) -> None: assert len(mock_connect.mock_calls) == 1 -async def test_setup_with_local_config(hass: HomeAssistant) -> None: +async def test_setup_with_local_config( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: """Test setup of local config.""" entry = MockConfigEntry( domain=mill.DOMAIN, @@ -119,7 +130,7 @@ async def test_setup_with_local_config(hass: HomeAssistant) -> None: assert len(mock_connect.mock_calls) == 1 -async def test_unload_entry(hass: HomeAssistant) -> None: +async def test_unload_entry(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test removing mill client.""" entry = MockConfigEntry( domain=mill.DOMAIN, diff --git a/tests/components/min_max/test_config_flow.py b/tests/components/min_max/test_config_flow.py index 93f8426e428..a9db7cab904 100644 --- a/tests/components/min_max/test_config_flow.py +++ b/tests/components/min_max/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.min_max.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("platform", ["sensor"]) @@ -55,17 +55,6 @@ async def test_config_flow(hass: HomeAssistant, platform: str) -> None: assert config_entry.title == "My min_max" -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - @pytest.mark.parametrize("platform", ["sensor"]) async def test_options(hass: HomeAssistant, platform: str) -> None: """Test reconfiguring.""" @@ -96,9 +85,9 @@ async def test_options(hass: HomeAssistant, platform: str) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "entity_ids") == input_sensors1 - assert get_suggested(schema, "round_digits") == 0 - assert get_suggested(schema, "type") == "min" + assert get_schema_suggested_value(schema, "entity_ids") == input_sensors1 + assert get_schema_suggested_value(schema, "round_digits") == 0 + assert get_schema_suggested_value(schema, "type") == "min" result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/motioneye/test_media_source.py b/tests/components/motioneye/test_media_source.py index f8a750d50da..c650e2ac59d 100644 --- a/tests/components/motioneye/test_media_source.py +++ b/tests/components/motioneye/test_media_source.py @@ -104,6 +104,7 @@ async def test_async_browse_media_success( "media_content_id": "media-source://motioneye", "can_play": False, "can_expand": True, + "can_search": False, "children_media_class": "directory", "thumbnail": None, "children": [ @@ -116,6 +117,7 @@ async def test_async_browse_media_success( ), "can_play": False, "can_expand": True, + "can_search": False, "thumbnail": None, "children_media_class": "directory", } @@ -132,6 +134,7 @@ async def test_async_browse_media_success( "media_content_id": "media-source://motioneye/74565ad414754616000674c87bdc876c", "can_play": False, "can_expand": True, + "can_search": False, "children_media_class": "directory", "thumbnail": None, "children": [ @@ -145,6 +148,7 @@ async def test_async_browse_media_success( ), "can_play": False, "can_expand": True, + "can_search": False, "thumbnail": None, "children_media_class": "directory", } @@ -164,6 +168,7 @@ async def test_async_browse_media_success( ), "can_play": False, "can_expand": True, + "can_search": False, "children_media_class": "directory", "thumbnail": None, "children": [ @@ -177,6 +182,7 @@ async def test_async_browse_media_success( ), "can_play": False, "can_expand": True, + "can_search": False, "thumbnail": None, "children_media_class": "video", }, @@ -190,6 +196,7 @@ async def test_async_browse_media_success( ), "can_play": False, "can_expand": True, + "can_search": False, "thumbnail": None, "children_media_class": "image", }, @@ -212,6 +219,7 @@ async def test_async_browse_media_success( ), "can_play": False, "can_expand": True, + "can_search": False, "children_media_class": "video", "thumbnail": None, "children": [ @@ -225,6 +233,7 @@ async def test_async_browse_media_success( ), "can_play": False, "can_expand": True, + "can_search": False, "thumbnail": None, "children_media_class": "directory", } @@ -247,6 +256,7 @@ async def test_async_browse_media_success( ), "can_play": False, "can_expand": True, + "can_search": False, "children_media_class": "video", "thumbnail": None, "children": [ @@ -261,6 +271,7 @@ async def test_async_browse_media_success( ), "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": "http://movie", "children_media_class": None, }, @@ -275,6 +286,7 @@ async def test_async_browse_media_success( ), "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": "http://movie", "children_media_class": None, }, @@ -289,6 +301,7 @@ async def test_async_browse_media_success( ), "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": "http://movie", "children_media_class": None, }, @@ -327,6 +340,7 @@ async def test_async_browse_media_images_success( ), "can_play": False, "can_expand": True, + "can_search": False, "children_media_class": "image", "thumbnail": None, "children": [ @@ -341,6 +355,7 @@ async def test_async_browse_media_images_success( ), "can_play": False, "can_expand": False, + "can_search": False, "thumbnail": "http://image", "children_media_class": None, } @@ -487,6 +502,7 @@ async def test_async_resolve_media_failure( ), "can_play": False, "can_expand": True, + "can_search": False, "children_media_class": "video", "thumbnail": None, "children": [], diff --git a/tests/components/motionmount/__init__.py b/tests/components/motionmount/__init__.py index 3b97c8aa7fe..b56b2c92678 100644 --- a/tests/components/motionmount/__init__.py +++ b/tests/components/motionmount/__init__.py @@ -7,6 +7,7 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo HOST = "192.168.1.31" PORT = 23 +MAC = bytes.fromhex("c4dd57f8a55f") TVM_ZEROCONF_SERVICE_TYPE = "_tvm._tcp.local." diff --git a/tests/components/motionmount/conftest.py b/tests/components/motionmount/conftest.py index 49f624b5266..795495f4457 100644 --- a/tests/components/motionmount/conftest.py +++ b/tests/components/motionmount/conftest.py @@ -6,9 +6,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.motionmount.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT -from . import HOST, PORT, ZEROCONF_MAC, ZEROCONF_NAME +from . import HOST, MAC, PORT, ZEROCONF_MAC, ZEROCONF_NAME from tests.common import MockConfigEntry @@ -24,6 +24,17 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_config_entry_with_pin() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=ZEROCONF_NAME, + domain=DOMAIN, + data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_PIN: 1234}, + unique_id=ZEROCONF_MAC, + ) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" @@ -34,12 +45,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_motionmount_config_flow() -> Generator[MagicMock]: +def mock_motionmount() -> Generator[MagicMock]: """Return a mocked MotionMount config flow.""" with patch( - "homeassistant.components.motionmount.config_flow.motionmount.MotionMount", + "homeassistant.components.motionmount.motionmount.MotionMount", autospec=True, ) as motionmount_mock: client = motionmount_mock.return_value + client.name = ZEROCONF_NAME + client.mac = MAC yield client diff --git a/tests/components/motionmount/test_config_flow.py b/tests/components/motionmount/test_config_flow.py index 1fa2715595d..f6c5e8d8cc3 100644 --- a/tests/components/motionmount/test_config_flow.py +++ b/tests/components/motionmount/test_config_flow.py @@ -35,10 +35,10 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_user_connection_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + mock_motionmount.connect.side_effect = ConnectionRefusedError() user_input = MOCK_USER_INPUT.copy() @@ -54,10 +54,10 @@ async def test_user_connection_error( async def test_user_connection_error_invalid_hostname( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when an invalid hostname is provided.""" - mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + mock_motionmount.connect.side_effect = socket.gaierror() user_input = MOCK_USER_INPUT.copy() @@ -73,10 +73,10 @@ async def test_user_connection_error_invalid_hostname( async def test_user_timeout_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a timeout error.""" - mock_motionmount_config_flow.connect.side_effect = TimeoutError() + mock_motionmount.connect.side_effect = TimeoutError() user_input = MOCK_USER_INPUT.copy() @@ -92,10 +92,10 @@ async def test_user_timeout_error( async def test_user_not_connected_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a not connected error.""" - mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + mock_motionmount.connect.side_effect = motionmount.NotConnectedError() user_input = MOCK_USER_INPUT.copy() @@ -111,13 +111,11 @@ async def test_user_not_connected_error( async def test_user_response_error_single_device_new_ce_old_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow creates an entry when there is a response error.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock( - return_value=b"\x00\x00\x00\x00\x00\x00" - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=b"\x00\x00\x00\x00\x00\x00") user_input = MOCK_USER_INPUT.copy() @@ -139,11 +137,11 @@ async def test_user_response_error_single_device_new_ce_old_pro( async def test_user_response_error_single_device_new_ce_new_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow creates an entry when there is a response error.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) user_input = MOCK_USER_INPUT.copy() @@ -167,13 +165,13 @@ async def test_user_response_error_single_device_new_ce_new_pro( async def test_user_response_error_multi_device_new_ce_new_pro( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there are multiple devices.""" mock_config_entry.add_to_hass(hass) - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) user_input = MOCK_USER_INPUT.copy() @@ -190,14 +188,12 @@ async def test_user_response_error_multi_device_new_ce_new_pro( async def test_user_response_authentication_needed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) user_input = MOCK_USER_INPUT.copy() @@ -211,12 +207,8 @@ async def test_user_response_authentication_needed( assert result["step_id"] == "auth" # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -236,10 +228,10 @@ async def test_user_response_authentication_needed( async def test_zeroconf_connection_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + mock_motionmount.connect.side_effect = ConnectionRefusedError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -255,10 +247,10 @@ async def test_zeroconf_connection_error( async def test_zeroconf_connection_error_invalid_hostname( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + mock_motionmount.connect.side_effect = socket.gaierror() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -274,10 +266,10 @@ async def test_zeroconf_connection_error_invalid_hostname( async def test_zeroconf_timout_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a timeout error.""" - mock_motionmount_config_flow.connect.side_effect = TimeoutError() + mock_motionmount.connect.side_effect = TimeoutError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -293,10 +285,10 @@ async def test_zeroconf_timout_error( async def test_zeroconf_not_connected_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a not connected error.""" - mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + mock_motionmount.connect.side_effect = motionmount.NotConnectedError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -312,12 +304,10 @@ async def test_zeroconf_not_connected_error( async def test_show_zeroconf_form_new_ce_old_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the zeroconf confirmation form is served.""" - type(mock_motionmount_config_flow).mac = PropertyMock( - return_value=b"\x00\x00\x00\x00\x00\x00" - ) + type(mock_motionmount).mac = PropertyMock(return_value=b"\x00\x00\x00\x00\x00\x00") discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) result = await hass.config_entries.flow.async_init( @@ -348,10 +338,10 @@ async def test_show_zeroconf_form_new_ce_old_pro( async def test_show_zeroconf_form_new_ce_new_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the zeroconf confirmation form is served.""" - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -383,7 +373,7 @@ async def test_show_zeroconf_form_new_ce_new_pro( async def test_zeroconf_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test we abort zeroconf flow if device already configured.""" mock_config_entry.add_to_hass(hass) @@ -402,13 +392,11 @@ async def test_zeroconf_device_exists_abort( async def test_zeroconf_authentication_needed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -421,12 +409,8 @@ async def test_zeroconf_authentication_needed( assert result["step_id"] == "auth" # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -448,17 +432,13 @@ async def test_zeroconf_authentication_needed( async def test_authentication_incorrect_then_correct_pin( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) user_input = MOCK_USER_INPUT.copy() @@ -483,9 +463,7 @@ async def test_authentication_incorrect_then_correct_pin( assert result["errors"][CONF_PIN] == CONF_PIN # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), @@ -505,18 +483,14 @@ async def test_authentication_incorrect_then_correct_pin( async def test_authentication_first_incorrect_pin_to_backoff( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - side_effect=[True, 1] - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(side_effect=[True, 1]) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -532,7 +506,7 @@ async def test_authentication_first_incorrect_pin_to_backoff( user_input=MOCK_PIN_INPUT.copy(), ) - assert mock_motionmount_config_flow.authenticate.called + assert mock_motionmount.authenticate.called assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backoff" @@ -541,12 +515,8 @@ async def test_authentication_first_incorrect_pin_to_backoff( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -567,16 +537,14 @@ async def test_authentication_first_incorrect_pin_to_backoff( async def test_authentication_multiple_incorrect_pins( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=1) user_input = MOCK_USER_INPUT.copy() @@ -602,12 +570,8 @@ async def test_authentication_multiple_incorrect_pins( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -628,16 +592,14 @@ async def test_authentication_multiple_incorrect_pins( async def test_authentication_show_backoff_when_still_running( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=1) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -671,12 +633,8 @@ async def test_authentication_show_backoff_when_still_running( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -697,17 +655,13 @@ async def test_authentication_show_backoff_when_still_running( async def test_authentication_correct_pin( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) user_input = MOCK_USER_INPUT.copy() @@ -720,9 +674,7 @@ async def test_authentication_correct_pin( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "auth" - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), @@ -741,11 +693,11 @@ async def test_authentication_correct_pin( async def test_full_user_flow_implementation( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test the full manual user flow from start to finish.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -773,11 +725,11 @@ async def test_full_user_flow_implementation( async def test_full_zeroconf_flow_implementation( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test the full zeroconf flow from start to finish.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -808,7 +760,7 @@ async def test_full_zeroconf_flow_implementation( async def test_full_reauth_flow_implementation( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test reauthentication.""" mock_config_entry.add_to_hass(hass) @@ -824,12 +776,8 @@ async def test_full_reauth_flow_implementation( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "auth" - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), diff --git a/tests/components/motionmount/test_entity.py b/tests/components/motionmount/test_entity.py new file mode 100644 index 00000000000..e335c3a913b --- /dev/null +++ b/tests/components/motionmount/test_entity.py @@ -0,0 +1,47 @@ +"""Tests for the MotionMount Entity base.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import format_mac + +from . import ZEROCONF_NAME + +from tests.common import MockConfigEntry + + +async def test_entity_rename( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.is_authenticated = True + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.async_block_till_done() + + mac = format_mac(mock_motionmount.mac.hex()) + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == ZEROCONF_NAME + + # Simulate the user changed the name of the device + mock_motionmount.name = "Blub" + + for callback in mock_motionmount.add_listener.call_args_list: + callback[0][0]() + await hass.async_block_till_done() + + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == "Blub" diff --git a/tests/components/motionmount/test_init.py b/tests/components/motionmount/test_init.py new file mode 100644 index 00000000000..e307945d0d0 --- /dev/null +++ b/tests/components/motionmount/test_init.py @@ -0,0 +1,129 @@ +"""Tests for the MotionMount init.""" + +from unittest.mock import MagicMock + +from homeassistant.components.motionmount import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import format_mac + +from tests.common import MockConfigEntry + + +async def test_setup_entry_with_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mac = format_mac(mock_motionmount.mac.hex()) + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == mock_config_entry.title + + +async def test_setup_entry_without_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.mac = b"\x00\x00\x00\x00\x00\x00" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + device = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert device + assert device.name == mock_config_entry.title + + +async def test_setup_entry_failed_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.connect.side_effect = TimeoutError() + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_wrong_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.mac = b"\x00\x00\x00\x00\x00\x01" + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_no_pin( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.is_authenticated = False + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert any(mock_config_entry.async_get_active_flows(hass, sources={SOURCE_REAUTH})) + + +async def test_setup_entry_wrong_pin( + hass: HomeAssistant, + mock_config_entry_with_pin: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry_with_pin.add_to_hass(hass) + + mock_motionmount.is_authenticated = False + assert not await hass.config_entries.async_setup( + mock_config_entry_with_pin.entry_id + ) + + assert mock_config_entry_with_pin.state is ConfigEntryState.SETUP_ERROR + assert any( + mock_config_entry_with_pin.async_get_active_flows(hass, sources={SOURCE_REAUTH}) + ) + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Test entries are unloaded correctly.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_motionmount.disconnect.call_count == 1 diff --git a/tests/components/motionmount/test_sensor.py b/tests/components/motionmount/test_sensor.py index 0320e62d640..0132860727f 100644 --- a/tests/components/motionmount/test_sensor.py +++ b/tests/components/motionmount/test_sensor.py @@ -7,12 +7,10 @@ import pytest from homeassistant.core import HomeAssistant -from . import ZEROCONF_NAME +from . import MAC, ZEROCONF_NAME from tests.common import MockConfigEntry -MAC = bytes.fromhex("c4dd57f8a55f") - @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index e4a368f0d71..3e920757f6b 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -87,6 +87,7 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT2 = { MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME = { "5269352dd9534c908d22812ea5d714cd": { "platform": "notify", + "name": None, "command_topic": "test-topic", "command_template": "{{ value }}", "entity_picture": "https://example.com/5269352dd9534c908d22812ea5d714cd", @@ -139,15 +140,23 @@ MOCK_SUBENTRY_SWITCH_COMPONENT = { }, } -# Bogus light component just for code coverage -# Note that light cannot be setup through the UI yet -# The test is for code coverage -MOCK_SUBENTRY_LIGHT_COMPONENT = { +MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT = { "8131babc5e8d4f44b82e0761d39091a2": { "platform": "light", - "name": "Test light", - "command_topic": "test-topic4", + "name": "Basic light", + "on_command_type": "last", + "optimistic": True, + "payload_off": "OFF", + "payload_on": "ON", + "command_topic": "test-topic", "schema": "basic", + "state_topic": "test-topic", + "color_temp_kelvin": True, + "state_value_template": "{{ value_json.value }}", + "brightness_scale": 255, + "max_kelvin": 6535, + "min_kelvin": 2000, + "white_scale": 255, "entity_picture": "https://example.com/8131babc5e8d4f44b82e0761d39091a2", }, } @@ -168,108 +177,57 @@ MOCK_SUBENTRY_AVAILABILITY_DATA = { } } +MOCK_SUBENTRY_DEVICE_DATA = { + "name": "Milk notifier", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", +} + MOCK_NOTIFY_SUBENTRY_DATA_MULTI = { - "device": { - "name": "Milk notifier", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2, } | MOCK_SUBENTRY_AVAILABILITY_DATA MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = { - "device": { - "name": "Milk notifier", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - "mqtt_settings": {"qos": 1}, - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 1}}, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1, } MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME = { - "device": { - "name": "Milk notifier", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME, } +MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT, +} MOCK_SENSOR_SUBENTRY_DATA_SINGLE = { - "device": { - "name": "Test sensor", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SENSOR_COMPONENT, } MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS = { - "device": { - "name": "Test sensor", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS, } MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE = { - "device": { - "name": "Test sensor", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET, } MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS = { - "device": { - "name": "Test switch", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SWITCH_COMPONENT, } MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA = { - "device": { - "name": "Milk notifier", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA, } MOCK_SUBENTRY_DATA_SET_MIX = { - "device": { - "name": "Milk notifier", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2 - | MOCK_SUBENTRY_LIGHT_COMPONENT + | MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT | MOCK_SUBENTRY_SWITCH_COMPONENT, } | MOCK_SUBENTRY_AVAILABILITY_DATA _SENTINEL = object() diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index c94d692b374..11f5b9d5c9e 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -33,6 +33,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .common import ( + MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, MOCK_NOTIFY_SUBENTRY_DATA_MULTI, MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, @@ -42,7 +43,7 @@ from .common import ( MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS, ) -from tests.common import MockConfigEntry, MockMqttReasonCode +from tests.common import MockConfigEntry, MockMqttReasonCode, get_schema_suggested_value from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient ADD_ON_DISCOVERY_INFO = { @@ -77,6 +78,16 @@ MOCK_CLIENT_KEY = ( b"## mock client key file ##" b"\n-----END PRIVATE KEY-----" ) +MOCK_EC_CLIENT_KEY = ( + b"-----BEGIN EC PRIVATE KEY-----\n" + b"## mock client key file ##" + b"\n-----END EC PRIVATE KEY-----" +) +MOCK_RSA_CLIENT_KEY = ( + b"-----BEGIN RSA PRIVATE KEY-----\n" + b"## mock client key file ##" + b"\n-----END RSA PRIVATE KEY-----" +) MOCK_ENCRYPTED_CLIENT_KEY = ( b"-----BEGIN ENCRYPTED PRIVATE KEY-----\n" b"## mock client key file ##\n" @@ -139,7 +150,13 @@ def mock_client_key_check_fail() -> Generator[MagicMock]: @pytest.fixture -def mock_ssl_context() -> Generator[dict[str, MagicMock]]: +def mock_context_client_key() -> bytes: + """Mock the client key in the moched ssl context.""" + return MOCK_CLIENT_KEY + + +@pytest.fixture +def mock_ssl_context(mock_context_client_key: bytes) -> Generator[dict[str, MagicMock]]: """Mock the SSL context used to load the cert chain and to load verify locations.""" with ( patch("homeassistant.components.mqtt.config_flow.SSLContext") as mock_context, @@ -156,9 +173,9 @@ def mock_ssl_context() -> Generator[dict[str, MagicMock]]: "homeassistant.components.mqtt.config_flow.load_der_x509_certificate" ) as mock_der_cert_check, ): - mock_pem_key_check().private_bytes.return_value = MOCK_CLIENT_KEY + mock_pem_key_check().private_bytes.return_value = mock_context_client_key mock_pem_cert_check().public_bytes.return_value = MOCK_GENERIC_CERT - mock_der_key_check().private_bytes.return_value = MOCK_CLIENT_KEY + mock_der_key_check().private_bytes.return_value = mock_context_client_key mock_der_cert_check().public_bytes.return_value = MOCK_GENERIC_CERT yield { "context": mock_context, @@ -1437,19 +1454,6 @@ def get_default(schema: vol.Schema, key: str) -> Any | None: return None -def get_suggested(schema: vol.Schema, key: str) -> Any | None: - """Get suggested value for key in voluptuous schema.""" - for schema_key in schema: # type:ignore[attr-defined] - if schema_key == key: - if ( - schema_key.description is None - or "suggested_value" not in schema_key.description - ): - return None - return schema_key.description["suggested_value"] - return None - - @pytest.mark.usefixtures("mock_reload_after_entry_update") async def test_option_flow_default_suggested_values( hass: HomeAssistant, @@ -1504,7 +1508,7 @@ async def test_option_flow_default_suggested_values( 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 + assert get_schema_suggested_value(result["data_schema"].schema, key) == value result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -1540,7 +1544,7 @@ async def test_option_flow_default_suggested_values( 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 + assert get_schema_suggested_value(result["data_schema"].schema, key) == value result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -1952,9 +1956,15 @@ async def test_options_bad_will_message_fails( } +@pytest.mark.parametrize( + "mock_context_client_key", + [MOCK_CLIENT_KEY, MOCK_EC_CLIENT_KEY, MOCK_RSA_CLIENT_KEY], +) @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 + hass: HomeAssistant, + mock_try_connection_success: MqttMockPahoClient, + mock_context_client_key: bytes, ) -> None: """Test config flow with advanced parameters from config.""" config_entry = MockConfigEntry( @@ -1974,7 +1984,7 @@ async def test_try_connection_with_advanced_parameters( mqtt.CONF_CERTIFICATE: "auto", mqtt.CONF_TLS_INSECURE: True, mqtt.CONF_CLIENT_CERT: MOCK_CLIENT_CERT.decode(encoding="utf-8)"), - mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"), + mqtt.CONF_CLIENT_KEY: mock_context_client_key.decode(encoding="utf-8"), mqtt.CONF_WS_PATH: "/path/", mqtt.CONF_WS_HEADERS: {"h1": "v1", "h2": "v2"}, mqtt.CONF_KEEPALIVE: 30, @@ -2016,7 +2026,7 @@ async def test_try_connection_with_advanced_parameters( for k, v in defaults.items(): assert get_default(result["data_schema"].schema, k) == v for k, v in suggested.items(): - assert get_suggested(result["data_schema"].schema, k) == v + assert get_schema_suggested_value(result["data_schema"].schema, k) == v # test we can change username and password mock_try_connection_success.reset_mock() @@ -2047,13 +2057,34 @@ async def test_try_connection_with_advanced_parameters( # check if tls_insecure_set is called assert mock_try_connection_success.tls_insecure_set.mock_calls[0][1] == (True,) - # check if the ca certificate settings were not set during connection test - assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ - "certfile" - ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_CERT) - assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ - "keyfile" - ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_KEY) + def read_file(path: Path) -> bytes: + with open(path, mode="rb") as file: + return file.read() + + # check if the client certificate settings saved + client_cert_path = await hass.async_add_executor_job( + mqtt.util.get_file_path, mqtt.CONF_CLIENT_CERT + ) + assert ( + mock_try_connection_success.tls_set.mock_calls[0].kwargs["certfile"] + == client_cert_path + ) + assert ( + await hass.async_add_executor_job(read_file, client_cert_path) + == MOCK_CLIENT_CERT + ) + + client_key_path = await hass.async_add_executor_job( + mqtt.util.get_file_path, mqtt.CONF_CLIENT_KEY + ) + assert ( + mock_try_connection_success.tls_set.mock_calls[0].kwargs["keyfile"] + == client_key_path + ) + assert ( + await hass.async_add_executor_job(read_file, client_key_path) + == mock_context_client_key + ) # check if websockets options are set assert mock_try_connection_success.ws_set_options.mock_calls[0][1] == ( @@ -2666,7 +2697,7 @@ async def test_migrate_of_incompatible_config_entry( ), ( MOCK_SENSOR_SUBENTRY_DATA_SINGLE, - {"name": "Test sensor", "mqtt_settings": {"qos": 0}}, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Energy"}, {"device_class": "enum", "options": ["low", "medium", "high"]}, ( @@ -2718,11 +2749,11 @@ async def test_migrate_of_incompatible_config_entry( {"state_topic": "invalid_subscribe_topic"}, ), ), - "Test sensor Energy", + "Milk notifier Energy", ), ( MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS, - {"name": "Test sensor", "mqtt_settings": {"qos": 0}}, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Energy"}, { "state_class": "measurement", @@ -2732,11 +2763,11 @@ async def test_migrate_of_incompatible_config_entry( "state_topic": "test-topic", }, (), - "Test sensor Energy", + "Milk notifier Energy", ), ( MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS, - {"name": "Test switch", "mqtt_settings": {"qos": 0}}, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Outlet"}, {"device_class": "outlet"}, (), @@ -2760,7 +2791,52 @@ async def test_migrate_of_incompatible_config_entry( {"state_topic": "invalid_subscribe_topic"}, ), ), - "Test switch Outlet", + "Milk notifier Outlet", + ), + ( + MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, + {"name": "Basic light"}, + {}, + {}, + { + "command_topic": "test-topic", + "state_topic": "test-topic", + "state_value_template": "{{ value_json.value }}", + "optimistic": True, + }, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ( + { + "command_topic": "test-topic", + "state_topic": "test-topic#invalid", + }, + {"state_topic": "invalid_subscribe_topic"}, + ), + ( + { + "command_topic": "test-topic", + "light_brightness_settings": { + "brightness_command_topic": "test-topic#invalid" + }, + }, + {"light_brightness_settings": "invalid_publish_topic"}, + ), + ( + { + "command_topic": "test-topic", + "advanced_settings": {"max_kelvin": 2000, "min_kelvin": 2000}, + }, + { + "advanced_settings": "max_below_min_kelvin", + }, + ), + ), + "Milk notifier Basic light", ), ], ids=[ @@ -2769,6 +2845,7 @@ async def test_migrate_of_incompatible_config_entry( "sensor_options", "sensor_total", "switch", + "light_basic_kelvin", ], ) async def test_subentry_configflow( @@ -3169,6 +3246,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( "user_input_platform_config_validation", "user_input_platform_config", "user_input_mqtt", + "component_data", "removed_options", ), [ @@ -3187,6 +3265,11 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( "command_template": "{{ value }}", "retain": True, }, + { + "command_topic": "test-topic1-updated", + "command_template": "{{ value }}", + "retain": True, + }, {"entity_picture"}, ), ( @@ -3223,10 +3306,38 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( "state_topic": "test-topic1-updated", "value_template": "{{ value_json.value }}", }, + { + "state_topic": "test-topic1-updated", + "value_template": "{{ value_json.value }}", + }, {"options", "expire_after", "entity_picture"}, ), + ( + ( + ConfigSubentryData( + data=MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + None, + None, + { + "command_topic": "test-topic1-updated", + "state_topic": "test-topic1-updated", + "light_brightness_settings": { + "brightness_command_template": "{{ value_json.value }}" + }, + }, + { + "command_topic": "test-topic1-updated", + "state_topic": "test-topic1-updated", + "brightness_command_template": "{{ value_json.value }}", + }, + {"optimistic", "state_value_template", "entity_picture"}, + ), ], - ids=["notify", "sensor"], + ids=["notify", "sensor", "light_basic"], ) async def test_subentry_reconfigure_edit_entity_single_entity( hass: HomeAssistant, @@ -3239,6 +3350,7 @@ async def test_subentry_reconfigure_edit_entity_single_entity( | None, user_input_platform_config: dict[str, Any] | None, user_input_mqtt: dict[str, Any], + component_data: dict[str, Any], removed_options: tuple[str, ...], ) -> None: """Test the subentry ConfigFlow reconfigure with single entity.""" @@ -3343,7 +3455,7 @@ async def test_subentry_reconfigure_edit_entity_single_entity( assert "entity_picture" not in new_components[component_id] # Check the second component was updated - for key, value in user_input_mqtt.items(): + for key, value in component_data.items(): assert new_components[component_id][key] == value assert set(component) - set(new_components[component_id]) == removed_options diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index 02289c8e476..cd87ce9717a 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -450,16 +450,82 @@ async def test_setting_device_tracker_location_via_lat_lon_message( assert state.attributes["latitude"] == 50.1 assert state.attributes["longitude"] == -2.1 assert state.attributes["gps_accuracy"] == 0 + assert state.attributes["source_type"] == "gps" assert state.state == STATE_NOT_HOME + # incomplete coordinates results in unknown state async_fire_mqtt_message(hass, "attributes-topic", '{"longitude": -117.22743}') state = hass.states.get("device_tracker.test") - assert state.attributes["longitude"] == -117.22743 + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes + assert state.attributes["source_type"] == "gps" assert state.state == STATE_UNKNOWN async_fire_mqtt_message(hass, "attributes-topic", '{"latitude":32.87336}') state = hass.states.get("device_tracker.test") - assert state.attributes["latitude"] == 32.87336 + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes + assert state.attributes["source_type"] == "gps" + assert state.state == STATE_UNKNOWN + + # invalid coordinates results in unknown state + async_fire_mqtt_message( + hass, "attributes-topic", '{"longitude": -117.22743, "latitude":null}' + ) + state = hass.states.get("device_tracker.test") + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes + assert state.attributes["source_type"] == "gps" + assert state.state == STATE_UNKNOWN + + # Test number validation + async_fire_mqtt_message( + hass, + "attributes-topic", + '{"latitude": "32.87336","longitude": "-117.22743", "gps_accuracy": "1.5", "source_type": "router"}', + ) + state = hass.states.get("device_tracker.test") + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes + assert "gps_accuracy" not in state.attributes + # assert source_type is overridden by discovery + assert state.attributes["source_type"] == "router" + assert state.state == STATE_UNKNOWN + + # Test with invalid GPS accuracy should default to 0, + # but location updates as expected + async_fire_mqtt_message( + hass, + "attributes-topic", + '{"latitude": 32.871234,"longitude": -117.21234, "gps_accuracy": "invalid", "source_type": "router"}', + ) + state = hass.states.get("device_tracker.test") + assert state.state == STATE_NOT_HOME + assert state.attributes["latitude"] == 32.871234 + assert state.attributes["longitude"] == -117.21234 + assert state.attributes["gps_accuracy"] == 0 + assert state.attributes["source_type"] == "router" + + # Test with invalid latitude + async_fire_mqtt_message( + hass, + "attributes-topic", + '{"latitude": null,"longitude": "-117.22743", "gps_accuracy": 1, "source_type": "router"}', + ) + state = hass.states.get("device_tracker.test") + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes + assert state.state == STATE_UNKNOWN + + # Test with invalid longitude + async_fire_mqtt_message( + hass, + "attributes-topic", + '{"latitude": 32.87336,"longitude": "unknown", "gps_accuracy": 1, "source_type": "router"}', + ) + state = hass.states.get("device_tracker.test") + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes assert state.state == STATE_UNKNOWN diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index f3264858095..7f7f32c4e43 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -330,7 +330,9 @@ async def test_no_color_brightness_color_temp_if_no_topics( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN - expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.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 @@ -581,6 +583,104 @@ async def test_controlling_state_color_temp_kelvin( assert state.attributes.get("hs_color") == (44.098, 2.43) # temp converted to color +@pytest.mark.parametrize( + ("hass_config", "expected_features"), + [ + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + } + } + }, + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "flash": True, + "transition": True, + } + } + }, + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "flash": True, + "transition": False, + } + } + }, + light.LightEntityFeature.FLASH, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "flash": False, + "transition": True, + } + } + }, + light.LightEntityFeature.TRANSITION, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "flash": False, + "transition": False, + } + } + }, + light.LightEntityFeature(0), + ), + ], + ids=[ + "default", + "explicit_on", + "flash_only", + "transition_only", + "no_flash_not_transition", + ], +) +async def test_flash_and_transition_feature_flags( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + expected_features: light.LightEntityFeature, +) -> None: + """Test for no RGB, brightness, color temp, effector XY.""" + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features + + @pytest.mark.parametrize( "hass_config", [ @@ -601,9 +701,11 @@ async def test_controlling_state_via_topic( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN expected_features = ( - light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + light.LightEntityFeature.EFFECT + | light.LightEntityFeature.FLASH + | light.LightEntityFeature.TRANSITION ) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features assert state.attributes.get("brightness") is None assert state.attributes.get("color_mode") is None assert state.attributes.get("color_temp_kelvin") is None @@ -799,9 +901,11 @@ async def test_sending_mqtt_commands_and_optimistic( state = hass.states.get("light.test") assert state.state == STATE_ON expected_features = ( - light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + light.LightEntityFeature.EFFECT + | light.LightEntityFeature.FLASH + | light.LightEntityFeature.TRANSITION ) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features assert state.attributes.get("brightness") == 95 assert state.attributes.get("color_mode") == "rgb" assert state.attributes.get("color_temp_kelvin") is None @@ -1457,9 +1561,11 @@ async def test_effect( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN expected_features = ( - light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + light.LightEntityFeature.EFFECT + | light.LightEntityFeature.FLASH + | light.LightEntityFeature.TRANSITION ) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features await common.async_turn_on(hass, "light.test") @@ -1523,8 +1629,10 @@ async def test_flash_short_and_long( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN - expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features await common.async_turn_on(hass, "light.test", flash="short") @@ -1586,8 +1694,10 @@ async def test_transition( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN - expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features await common.async_turn_on(hass, "light.test", transition=15) mqtt_mock.async_publish.assert_called_once_with( @@ -1766,8 +1876,10 @@ async def test_invalid_values( 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_FLASH | light.SUPPORT_TRANSITION - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is 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 diff --git a/tests/components/music_assistant/snapshots/test_media_player.ambr b/tests/components/music_assistant/snapshots/test_media_player.ambr index 50223ddf623..f561a5c3afb 100644 --- a/tests/components/music_assistant/snapshots/test_media_player.ambr +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -28,7 +28,7 @@ 'original_name': None, 'platform': 'music_assistant', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:02', 'unit_of_measurement': None, @@ -54,7 +54,7 @@ 'media_duration': 300, 'media_position': 0, 'media_title': 'Test Track', - 'supported_features': , + 'supported_features': , 'volume_level': 0.2, }), 'context': , @@ -94,7 +94,7 @@ 'original_name': None, 'platform': 'music_assistant', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'test_group_player_1', 'unit_of_measurement': None, @@ -125,7 +125,7 @@ 'media_title': 'November Rain', 'repeat': 'all', 'shuffle': True, - 'supported_features': , + 'supported_features': , 'volume_level': 0.06, }), 'context': , @@ -165,7 +165,7 @@ 'original_name': None, 'platform': 'music_assistant', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:01', 'unit_of_measurement': None, @@ -181,7 +181,7 @@ ]), 'icon': 'mdi:speaker', 'mass_player_type': 'player', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'media_player.test_player_1', diff --git a/tests/components/music_assistant/test_media_browser.py b/tests/components/music_assistant/test_media_browser.py index 96fd54962d8..5a456e9dcb0 100644 --- a/tests/components/music_assistant/test_media_browser.py +++ b/tests/components/music_assistant/test_media_browser.py @@ -1,18 +1,31 @@ """Test Music Assistant media browser implementation.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.media_player import BrowseError, BrowseMedia, MediaType +from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + MediaClass, + MediaType, + SearchError, + SearchMedia, + SearchMediaQuery, +) from homeassistant.components.music_assistant.const import DOMAIN from homeassistant.components.music_assistant.media_browser import ( LIBRARY_ALBUMS, LIBRARY_ARTISTS, + LIBRARY_AUDIOBOOKS, LIBRARY_PLAYLISTS, + LIBRARY_PODCASTS, LIBRARY_RADIO, LIBRARY_TRACKS, + MEDIA_TYPE_AUDIOBOOK, + MEDIA_TYPE_RADIO, async_browse_media, + async_search_media, ) from homeassistant.core import HomeAssistant @@ -25,8 +38,10 @@ from .common import setup_integration_from_fixtures (LIBRARY_PLAYLISTS, MediaType.PLAYLIST, "library://playlist/40"), (LIBRARY_ARTISTS, MediaType.ARTIST, "library://artist/127"), (LIBRARY_ALBUMS, MediaType.ALBUM, "library://album/396"), - (LIBRARY_TRACKS, MediaType.TRACK, "library://track/486"), + (LIBRARY_TRACKS, MediaType.TRACK, "library://track/456"), (LIBRARY_RADIO, DOMAIN, "library://radio/1"), + (LIBRARY_PODCASTS, MediaType.PODCAST, "library://podcast/6"), + (LIBRARY_AUDIOBOOKS, DOMAIN, "library://audiobook/1"), ("artist", MediaType.ARTIST, "library://album/115"), ("album", MediaType.ALBUM, "library://track/247"), ("playlist", DOMAIN, "tidal--Ah76MuMg://track/77616130"), @@ -63,3 +78,249 @@ async def test_browse_media_not_found( with pytest.raises(BrowseError, match="Media not found: unknown / unknown"): await async_browse_media(hass, music_assistant_client, "unknown", "unknown") + + +class MockSearchResults: + """Mock search results.""" + + def __init__(self, media_types: list[str]) -> None: + """Initialize mock search results.""" + self.artists = [] + self.albums = [] + self.tracks = [] + self.playlists = [] + self.radio = [] + self.podcasts = [] + self.audiobooks = [] + + # Create mock items based on requested media types + for media_type in media_types: + items = [] + for i in range(5): # Create 5 mock items for each type + item = MagicMock() + item.name = f"Test {media_type} {i}" + item.uri = f"library://{media_type}/{i}" + item.available = True + item.artists = [] + media_type_mock = MagicMock() + media_type_mock.value = media_type + item.media_type = media_type_mock + items.append(item) + + # Assign to the appropriate attribute + if media_type == "artist": + self.artists = items + elif media_type == "album": + self.albums = items + elif media_type == "track": + self.tracks = items + elif media_type == "playlist": + self.playlists = items + elif media_type == "radio": + self.radio = items + elif media_type == "podcast": + self.podcasts = items + elif media_type == "audiobook": + self.audiobooks = items + + +@pytest.mark.parametrize( + ("search_query", "media_content_type", "expected_items"), + [ + # Search for tracks + ("track", MediaType.TRACK, 5), + # Search for albums + ("album", MediaType.ALBUM, 5), + # Search for artists + ("artist", MediaType.ARTIST, 5), + # Search for playlists + ("playlist", MediaType.PLAYLIST, 5), + # Search for radio stations + ("radio", MEDIA_TYPE_RADIO, 5), + # Search for podcasts + ("podcast", MediaType.PODCAST, 5), + # Search for audiobooks + ("audiobook", MEDIA_TYPE_AUDIOBOOK, 5), + # Search with no media type specified (should return all types) + ("music", None, 35), + ], +) +async def test_search_media( + hass: HomeAssistant, + music_assistant_client: MagicMock, + search_query: str, + media_content_type: str, + expected_items: int, +) -> None: + """Test the async_search_media method with different content types.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + + # Create mock search results + media_types = [] + if media_content_type == MediaType.TRACK: + media_types = ["track"] + elif media_content_type == MediaType.ALBUM: + media_types = ["album"] + elif media_content_type == MediaType.ARTIST: + media_types = ["artist"] + elif media_content_type == MediaType.PLAYLIST: + media_types = ["playlist"] + elif media_content_type == MEDIA_TYPE_RADIO: + media_types = ["radio"] + elif media_content_type == MediaType.PODCAST: + media_types = ["podcast"] + elif media_content_type == MEDIA_TYPE_AUDIOBOOK: + media_types = ["audiobook"] + elif media_content_type is None: + media_types = [ + "artist", + "album", + "track", + "playlist", + "radio", + "podcast", + "audiobook", + ] + + mock_results = MockSearchResults(media_types) + + # Use patch instead of trying to mock return_value + with patch.object( + music_assistant_client.music, "search", return_value=mock_results + ): + # Create search query + query = SearchMediaQuery( + search_query=search_query, + media_content_type=media_content_type, + ) + + # Perform search + search_results = await async_search_media(music_assistant_client, query) + + # Verify search results + assert isinstance(search_results, SearchMedia) + + if media_content_type is not None: + # For specific media types, expect up to 5 results + assert len(search_results.result) <= 5 + else: + # For "all types" search, we'd expect items from each type + # But since we're returning exactly 5 items per type (from mock) + # we'd expect 5 * 7 = 35 items maximum + assert len(search_results.result) <= 35 + + +@pytest.mark.parametrize( + ("search_query", "media_filter_classes", "expected_media_types"), + [ + # Search for tracks + ("track", {MediaClass.TRACK}, ["track"]), + # Search for albums + ("album", {MediaClass.ALBUM}, ["album"]), + # Search for artists + ("artist", {MediaClass.ARTIST}, ["artist"]), + # Search for playlists + ("playlist", {MediaClass.PLAYLIST}, ["playlist"]), + # Search for multiple media classes + ("music", {MediaClass.ALBUM, MediaClass.TRACK}, ["album", "track"]), + ], +) +async def test_search_media_with_filter_classes( + hass: HomeAssistant, + music_assistant_client: MagicMock, + search_query: str, + media_filter_classes: set[MediaClass], + expected_media_types: list[str], +) -> None: + """Test the async_search_media method with different media filter classes.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + + # Create mock search results + mock_results = MockSearchResults(expected_media_types) + + # Use patch instead of trying to mock return_value directly + with patch.object( + music_assistant_client.music, "search", return_value=mock_results + ): + # Create search query + query = SearchMediaQuery( + search_query=search_query, + media_filter_classes=media_filter_classes, + ) + + # Perform search + search_results = await async_search_media(music_assistant_client, query) + + # Verify search results + assert isinstance(search_results, SearchMedia) + expected_items = len(expected_media_types) * 5 # 5 items per media type + assert len(search_results.result) <= expected_items + + +async def test_search_media_within_album( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test searching within an album context.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + + # Mock album and tracks + album = MagicMock() + album.item_id = "396" + album.provider = "library" + + tracks = [] + for i in range(5): + track = MagicMock() + track.name = f"Test Track {i}" + track.uri = f"library://track/{i}" + track.available = True + track.artists = [] + media_type_mock = MagicMock() + media_type_mock.value = "track" + track.media_type = media_type_mock + tracks.append(track) + + # Set up mocks using patch + with ( + patch.object( + music_assistant_client.music, "get_item_by_uri", return_value=album + ), + patch.object( + music_assistant_client.music, "get_album_tracks", return_value=tracks + ), + ): + # Create search query within an album + album_uri = "library://album/396" + query = SearchMediaQuery( + search_query="track", + media_content_id=album_uri, + ) + + # Perform search + search_results = await async_search_media(music_assistant_client, query) + + # Verify search results + assert isinstance(search_results, SearchMedia) + assert len(search_results.result) > 0 # Should have results + + +async def test_search_media_error( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that search errors are properly handled.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + + # Use patch to cause an exception + with patch.object( + music_assistant_client.music, "search", side_effect=Exception("Search failed") + ): + # Create search query + query = SearchMediaQuery( + search_query="error test", + ) + + # Verify that the error is caught and a SearchError is raised + with pytest.raises(SearchError, match="Error searching for error test"): + await async_search_media(music_assistant_client, query) diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index ad321a1cc29..00ba6bc8093 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -651,6 +651,7 @@ async def test_media_player_supported_features( | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.SEARCH_MEDIA ) assert state.attributes["supported_features"] == expected_features # remove power control capability from player, trigger subscription callback diff --git a/tests/components/namecheapdns/test_init.py b/tests/components/namecheapdns/test_init.py index 1d5b4ca5949..b7c1fe732c0 100644 --- a/tests/components/namecheapdns/test_init.py +++ b/tests/components/namecheapdns/test_init.py @@ -18,7 +18,7 @@ PASSWORD = "abcdefgh" @pytest.fixture -def setup_namecheapdns( +async def setup_namecheapdns( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Fixture that sets up NamecheapDNS.""" @@ -28,12 +28,10 @@ def setup_namecheapdns( text="0", ) - hass.loop.run_until_complete( - async_setup_component( - hass, - namecheapdns.DOMAIN, - {"namecheapdns": {"host": HOST, "domain": DOMAIN, "password": PASSWORD}}, - ) + await async_setup_component( + hass, + namecheapdns.DOMAIN, + {"namecheapdns": {"host": HOST, "domain": DOMAIN, "password": PASSWORD}}, ) diff --git a/tests/components/netatmo/fixtures/homesdata.json b/tests/components/netatmo/fixtures/homesdata.json index ccc71dc6b41..344d3ecc29c 100644 --- a/tests/components/netatmo/fixtures/homesdata.json +++ b/tests/components/netatmo/fixtures/homesdata.json @@ -630,7 +630,7 @@ "name": "Default", "selected": true, "id": "591b54a2764ff4d50d8b5795", - "type": "therm" + "type": "cooling" }, { "zones": [ @@ -778,6 +778,8 @@ } ], "therm_setpoint_default_duration": 120, + "temperature_control_mode": "cooling", + "cooling_mode": "schedule", "persons": [ { "id": "91827374-7e04-5298-83ad-a0cb8372dff1", diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index 4ea7e30bcf9..3a66aa84c41 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -8,6 +8,7 @@ 'homes': list([ dict({ 'altitude': 112, + 'cooling_mode': 'schedule', 'coordinates': '**REDACTED**', 'country': 'DE', 'id': '91763b24c43d3e344f424e8b', @@ -539,7 +540,7 @@ 'name': '**REDACTED**', 'selected': True, 'timetable': '**REDACTED**', - 'type': 'therm', + 'type': 'cooling', 'zones': '**REDACTED**', }), dict({ @@ -552,6 +553,7 @@ 'zones': '**REDACTED**', }), ]), + 'temperature_control_mode': 'cooling', 'therm_mode': 'schedule', 'therm_setpoint_default_duration': 120, 'timezone': 'Europe/Berlin', diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr index 00285f565a6..8b974027116 100644 --- a/tests/components/netatmo/snapshots/test_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -499,7 +499,7 @@ 'state': 'unknown', }) # --- -# name: test_entity[sensor.baby_bedroom_wi_fi-entry] +# name: test_entity[sensor.baby_bedroom_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -512,7 +512,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.baby_bedroom_wi_fi', + 'entity_id': 'sensor.baby_bedroom_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -524,7 +524,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -533,16 +533,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.baby_bedroom_wi_fi-state] +# name: test_entity[sensor.baby_bedroom_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Baby Bedroom Wi-Fi', + 'friendly_name': 'Baby Bedroom Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.baby_bedroom_wi_fi', + 'entity_id': 'sensor.baby_bedroom_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1033,7 +1033,7 @@ 'state': 'unavailable', }) # --- -# name: test_entity[sensor.bedroom_wi_fi-entry] +# name: test_entity[sensor.bedroom_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1046,7 +1046,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.bedroom_wi_fi', + 'entity_id': 'sensor.bedroom_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1058,7 +1058,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -1067,16 +1067,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.bedroom_wi_fi-state] +# name: test_entity[sensor.bedroom_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Bedroom Wi-Fi', + 'friendly_name': 'Bedroom Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.bedroom_wi_fi', + 'entity_id': 'sensor.bedroom_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -3668,7 +3668,7 @@ 'state': 'unknown', }) # --- -# name: test_entity[sensor.kitchen_wi_fi-entry] +# name: test_entity[sensor.kitchen_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3681,7 +3681,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.kitchen_wi_fi', + 'entity_id': 'sensor.kitchen_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3693,7 +3693,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -3702,16 +3702,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.kitchen_wi_fi-state] +# name: test_entity[sensor.kitchen_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Kitchen Wi-Fi', + 'friendly_name': 'Kitchen Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.kitchen_wi_fi', + 'entity_id': 'sensor.kitchen_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4511,7 +4511,7 @@ 'state': 'unknown', }) # --- -# name: test_entity[sensor.livingroom_wi_fi-entry] +# name: test_entity[sensor.livingroom_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4524,7 +4524,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.livingroom_wi_fi', + 'entity_id': 'sensor.livingroom_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4536,7 +4536,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -4545,16 +4545,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.livingroom_wi_fi-state] +# name: test_entity[sensor.livingroom_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Livingroom Wi-Fi', + 'friendly_name': 'Livingroom Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.livingroom_wi_fi', + 'entity_id': 'sensor.livingroom_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -5061,7 +5061,7 @@ 'state': 'unknown', }) # --- -# name: test_entity[sensor.parents_bedroom_wi_fi-entry] +# name: test_entity[sensor.parents_bedroom_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5074,7 +5074,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.parents_bedroom_wi_fi', + 'entity_id': 'sensor.parents_bedroom_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5086,7 +5086,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -5095,16 +5095,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.parents_bedroom_wi_fi-state] +# name: test_entity[sensor.parents_bedroom_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Parents Bedroom Wi-Fi', + 'friendly_name': 'Parents Bedroom Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.parents_bedroom_wi_fi', + 'entity_id': 'sensor.parents_bedroom_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -5586,54 +5586,6 @@ 'state': '55', }) # --- -# name: test_entity[sensor.villa_bathroom_radio-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.villa_bathroom_radio', - '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': 'Radio', - 'platform': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rf_strength', - 'unique_id': '12:34:56:80:7e:18-rf_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_entity[sensor.villa_bathroom_radio-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Bathroom Radio', - }), - 'context': , - 'entity_id': 'sensor.villa_bathroom_radio', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Full', - }) -# --- # name: test_entity[sensor.villa_bathroom_reachability-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5682,6 +5634,54 @@ 'state': 'True', }) # --- +# name: test_entity[sensor.villa_bathroom_rf_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_bathroom_rf_strength', + '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': 'RF strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rf_strength', + 'unique_id': '12:34:56:80:7e:18-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_bathroom_rf_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Villa Bathroom RF strength', + }), + 'context': , + 'entity_id': 'sensor.villa_bathroom_rf_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Full', + }) +# --- # name: test_entity[sensor.villa_bathroom_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5945,54 +5945,6 @@ 'state': '53', }) # --- -# name: test_entity[sensor.villa_bedroom_radio-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.villa_bedroom_radio', - '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': 'Radio', - 'platform': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rf_strength', - 'unique_id': '12:34:56:80:44:92-rf_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_entity[sensor.villa_bedroom_radio-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Bedroom Radio', - }), - 'context': , - 'entity_id': 'sensor.villa_bedroom_radio', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'High', - }) -# --- # name: test_entity[sensor.villa_bedroom_reachability-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6041,6 +5993,54 @@ 'state': 'True', }) # --- +# name: test_entity[sensor.villa_bedroom_rf_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_bedroom_rf_strength', + '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': 'RF strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rf_strength', + 'unique_id': '12:34:56:80:44:92-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_bedroom_rf_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Villa Bedroom RF strength', + }), + 'context': , + 'entity_id': 'sensor.villa_bedroom_rf_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'High', + }) +# --- # name: test_entity[sensor.villa_bedroom_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6429,54 +6429,6 @@ 'state': '9', }) # --- -# name: test_entity[sensor.villa_garden_radio-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.villa_garden_radio', - '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': 'Radio', - 'platform': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rf_strength', - 'unique_id': '12:34:56:03:1b:e4-rf_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_entity[sensor.villa_garden_radio-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Garden Radio', - }), - 'context': , - 'entity_id': 'sensor.villa_garden_radio', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Full', - }) -# --- # name: test_entity[sensor.villa_garden_reachability-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6525,6 +6477,54 @@ 'state': 'True', }) # --- +# name: test_entity[sensor.villa_garden_rf_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_garden_rf_strength', + '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': 'RF strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rf_strength', + 'unique_id': '12:34:56:03:1b:e4-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_garden_rf_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Villa Garden RF strength', + }), + 'context': , + 'entity_id': 'sensor.villa_garden_rf_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Full', + }) +# --- # name: test_entity[sensor.villa_garden_wind_angle-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6917,54 +6917,6 @@ 'state': 'unavailable', }) # --- -# name: test_entity[sensor.villa_outdoor_radio-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.villa_outdoor_radio', - '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': 'Radio', - 'platform': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rf_strength', - 'unique_id': '12:34:56:80:1c:42-rf_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_entity[sensor.villa_outdoor_radio-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Outdoor Radio', - }), - 'context': , - 'entity_id': 'sensor.villa_outdoor_radio', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'High', - }) -# --- # name: test_entity[sensor.villa_outdoor_reachability-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7013,6 +6965,54 @@ 'state': 'False', }) # --- +# name: test_entity[sensor.villa_outdoor_rf_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_outdoor_rf_strength', + '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': 'RF strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rf_strength', + 'unique_id': '12:34:56:80:1c:42-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_outdoor_rf_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Villa Outdoor RF strength', + }), + 'context': , + 'entity_id': 'sensor.villa_outdoor_rf_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'High', + }) +# --- # name: test_entity[sensor.villa_outdoor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7382,54 +7382,6 @@ 'state': '6.9', }) # --- -# name: test_entity[sensor.villa_rain_radio-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.villa_rain_radio', - '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': 'Radio', - 'platform': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rf_strength', - 'unique_id': '12:34:56:80:c1:ea-rf_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_entity[sensor.villa_rain_radio-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Rain Radio', - }), - 'context': , - 'entity_id': 'sensor.villa_rain_radio', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Medium', - }) -# --- # name: test_entity[sensor.villa_rain_reachability-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7478,6 +7430,54 @@ 'state': 'True', }) # --- +# name: test_entity[sensor.villa_rain_rf_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_rain_rf_strength', + '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': 'RF strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rf_strength', + 'unique_id': '12:34:56:80:c1:ea-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_rain_rf_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Villa Rain RF strength', + }), + 'context': , + 'entity_id': 'sensor.villa_rain_rf_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Medium', + }) +# --- # name: test_entity[sensor.villa_reachability-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7636,7 +7636,7 @@ 'state': 'stable', }) # --- -# name: test_entity[sensor.villa_wi_fi-entry] +# name: test_entity[sensor.villa_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -7649,7 +7649,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.villa_wi_fi', + 'entity_id': 'sensor.villa_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -7661,7 +7661,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -7670,16 +7670,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.villa_wi_fi-state] +# name: test_entity[sensor.villa_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Wi-Fi', + 'friendly_name': 'Villa Wi-Fi strength', 'latitude': 46.123456, 'longitude': 6.1234567, }), 'context': , - 'entity_id': 'sensor.villa_wi_fi', + 'entity_id': 'sensor.villa_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/netatmo/test_media_source.py b/tests/components/netatmo/test_media_source.py index f9aff2749d2..3d787a1a813 100644 --- a/tests/components/netatmo/test_media_source.py +++ b/tests/components/netatmo/test_media_source.py @@ -57,8 +57,10 @@ async def test_async_browse_media(hass: HomeAssistant) -> None: # Test invalid base with pytest.raises(BrowseError) as excinfo: await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/") - assert str(excinfo.value) == "Invalid media source URI" - + assert str(excinfo.value) == ( + "Failed to browse media with content id media-source://netatmo/: " + "Invalid media source URI" + ) # Test successful listing media = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/events") diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index 2c47cdefa60..e9e1ff4739e 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -153,7 +153,7 @@ async def test_process_health(health: int, expected: str) -> None: ("uid", "name", "expected"), [ ("12:34:56:03:1b:e4-reachable", "villa_garden_reachable", "True"), - ("12:34:56:03:1b:e4-rf_status", "villa_garden_radio", "Full"), + ("12:34:56:03:1b:e4-rf_status", "villa_garden_rf_strength", "Full"), ( "12:34:56:80:bb:26-wifi_status", "villa_wifi_strength", @@ -205,7 +205,7 @@ async def test_process_health(health: int, expected: str) -> None: ), ( "12:34:56:26:68:92-wifi_status", - "baby_bedroom_wifi", + "baby_bedroom_wifi_strength", "High", ), ("Home-max-windangle_value", "home_max_wind_angle", "17"), diff --git a/tests/components/network/snapshots/test_init.ambr b/tests/components/network/snapshots/test_init.ambr new file mode 100644 index 00000000000..268c8e0d44f --- /dev/null +++ b/tests/components/network/snapshots/test_init.ambr @@ -0,0 +1,22 @@ +# serializer version: 1 +# name: test_repair_docker_host_network_without_host_networking[mock_socket0] + IssueRegistryItemSnapshot({ + 'active': True, + 'breaks_in_ha_version': None, + 'created': , + 'data': None, + 'dismissed_version': None, + 'domain': 'network', + 'is_fixable': False, + 'is_persistent': False, + 'issue_domain': None, + 'issue_id': 'docker_host_network', + 'learn_more_url': 'https://www.home-assistant.io/installation/linux#install-home-assistant-container', + 'severity': , + 'translation_key': 'docker_host_network', + 'translation_placeholders': dict({ + 'docs_url': 'https://docs.docker.com/network/network-tutorial-host/', + 'install_url': 'https://www.home-assistant.io/installation/linux#install-home-assistant-container', + }), + }) +# --- diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index a2352e6af9e..372dba1772d 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -1,10 +1,13 @@ """Test the Network Configuration.""" +from __future__ import annotations + from ipaddress import IPv4Address from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import network from homeassistant.components.network.const import ( @@ -17,6 +20,7 @@ from homeassistant.components.network.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from . import LOOPBACK_IPADDR, NO_LOOPBACK_IPADDR @@ -801,3 +805,48 @@ async def test_websocket_network_url( "external": None, "cloud": None, } + + +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") +async def test_repair_docker_host_network_not_docker( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test repair is not created when not in Docker.""" + with patch("homeassistant.util.package.is_docker_env", return_value=False): + assert await async_setup_component(hass, "network", {}) + + assert not issue_registry.async_get_issue(DOMAIN, "docker_host_network") + + +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") +async def test_repair_docker_host_network_with_host_networking( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test repair is not created when in Docker with host networking.""" + with ( + patch("homeassistant.util.package.is_docker_env", return_value=True), + patch("homeassistant.components.network.Path.exists", return_value=True), + ): + assert await async_setup_component(hass, "network", {}) + + assert not issue_registry.async_get_issue(DOMAIN, "docker_host_network") + + +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") +async def test_repair_docker_host_network_without_host_networking( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test repair is created when in Docker without host networking.""" + with ( + patch("homeassistant.util.package.is_docker_env", return_value=True), + patch("homeassistant.components.network.Path.exists", return_value=False), + ): + assert await async_setup_component(hass, "network", {}) + + assert (issue := issue_registry.async_get_issue(DOMAIN, "docker_host_network")) + assert issue == snapshot diff --git a/tests/components/no_ip/test_init.py b/tests/components/no_ip/test_init.py index e344b984e7d..4e9c5d67c74 100644 --- a/tests/components/no_ip/test_init.py +++ b/tests/components/no_ip/test_init.py @@ -22,22 +22,20 @@ USERNAME = "abc@123.com" @pytest.fixture -def setup_no_ip(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def setup_no_ip(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Fixture that sets up NO-IP.""" aioclient_mock.get(UPDATE_URL, params={"hostname": DOMAIN}, text="good 0.0.0.0") - hass.loop.run_until_complete( - async_setup_component( - hass, - no_ip.DOMAIN, - { - no_ip.DOMAIN: { - "domain": DOMAIN, - "username": USERNAME, - "password": PASSWORD, - } - }, - ) + await async_setup_component( + hass, + no_ip.DOMAIN, + { + no_ip.DOMAIN: { + "domain": DOMAIN, + "username": USERNAME, + "password": PASSWORD, + } + }, ) diff --git a/tests/components/ntfy/__init__.py b/tests/components/ntfy/__init__.py new file mode 100644 index 00000000000..e059dc61ae9 --- /dev/null +++ b/tests/components/ntfy/__init__.py @@ -0,0 +1 @@ +"""Tests for ntfy integration.""" diff --git a/tests/components/ntfy/conftest.py b/tests/components/ntfy/conftest.py new file mode 100644 index 00000000000..d9bc620b464 --- /dev/null +++ b/tests/components/ntfy/conftest.py @@ -0,0 +1,79 @@ +"""Common fixtures for the ntfy tests.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +from aiontfy import Account, AccountTokenResponse +import pytest + +from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ntfy.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_aiontfy() -> Generator[AsyncMock]: + """Mock aiontfy.""" + + with ( + patch("homeassistant.components.ntfy.Ntfy", autospec=True) as mock_client, + patch("homeassistant.components.ntfy.config_flow.Ntfy", new=mock_client), + ): + client = mock_client.return_value + + client.publish.return_value = {} + client.account.return_value = Account.from_json( + load_fixture("account.json", DOMAIN) + ) + client.generate_token.return_value = AccountTokenResponse( + token="token", last_access=datetime.now() + ) + yield client + + +@pytest.fixture(autouse=True) +def mock_random() -> Generator[MagicMock]: + """Mock random.""" + + with patch( + "homeassistant.components.ntfy.config_flow.random.choices", + return_value=["randomtopic"], + ) as mock_client: + yield mock_client + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock ntfy configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: None, + CONF_TOKEN: "token", + CONF_VERIFY_SSL: True, + }, + entry_id="123456789", + subentries_data=[ + ConfigSubentryData( + data={CONF_TOPIC: "mytopic"}, + subentry_id="ABCDEF", + subentry_type="topic", + title="mytopic", + unique_id="mytopic", + ) + ], + ) diff --git a/tests/components/ntfy/fixtures/account.json b/tests/components/ntfy/fixtures/account.json new file mode 100644 index 00000000000..8b4ee501a4d --- /dev/null +++ b/tests/components/ntfy/fixtures/account.json @@ -0,0 +1,59 @@ +{ + "username": "username", + "role": "user", + "sync_topic": "st_xxxxxxxxxxxxx", + "language": "en", + "notification": { + "min_priority": 2, + "delete_after": 604800 + }, + "subscriptions": [ + { + "base_url": "http://localhost", + "topic": "test", + "display_name": null + } + ], + "reservations": [ + { + "topic": "test", + "everyone": "read-only" + } + ], + "tokens": [ + { + "token": "tk_xxxxxxxxxxxxxxxxxxxxxxxxxx", + "last_access": 1743362634, + "last_origin": "172.17.0.1", + "expires": 1743621234 + } + ], + "tier": { + "code": "starter", + "name": "starter" + }, + "limits": { + "basis": "tier", + "messages": 5000, + "messages_expiry_duration": 43200, + "emails": 20, + "calls": 0, + "reservations": 3, + "attachment_total_size": 104857600, + "attachment_file_size": 15728640, + "attachment_expiry_duration": 21600, + "attachment_bandwidth": 1073741824 + }, + "stats": { + "messages": 10, + "messages_remaining": 4990, + "emails": 0, + "emails_remaining": 20, + "calls": 0, + "calls_remaining": 0, + "reservations": 1, + "reservations_remaining": 2, + "attachment_total_size": 0, + "attachment_total_size_remaining": 104857600 + } +} diff --git a/tests/components/ntfy/snapshots/test_diagnostics.ambr b/tests/components/ntfy/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..3dd464f8670 --- /dev/null +++ b/tests/components/ntfy/snapshots/test_diagnostics.ambr @@ -0,0 +1,24 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'topics': dict({ + 'ABCDEF': dict({ + 'data': dict({ + 'topic': 'mytopic', + }), + 'subentry_id': 'ABCDEF', + 'subentry_type': 'topic', + 'title': 'mytopic', + 'unique_id': 'mytopic', + }), + }), + 'url': 'https://ntfy.sh/', + }) +# --- +# name: test_diagnostics_redacted_url + dict({ + 'topics': dict({ + }), + 'url': 'http://**redacted**/', + }) +# --- diff --git a/tests/components/ntfy/snapshots/test_notify.ambr b/tests/components/ntfy/snapshots/test_notify.ambr new file mode 100644 index 00000000000..619ae59cc2f --- /dev/null +++ b/tests/components/ntfy/snapshots/test_notify.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_notify_platform[notify.mytopic-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'notify', + 'entity_category': None, + 'entity_id': 'notify.mytopic', + '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': 'ntfy', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'publish', + 'unique_id': '123456789_ABCDEF_publish', + 'unit_of_measurement': None, + }) +# --- +# name: test_notify_platform[notify.mytopic-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mytopic', + 'supported_features': , + }), + 'context': , + 'entity_id': 'notify.mytopic', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/ntfy/test_config_flow.py b/tests/components/ntfy/test_config_flow.py new file mode 100644 index 00000000000..2d3656536a9 --- /dev/null +++ b/tests/components/ntfy/test_config_flow.py @@ -0,0 +1,500 @@ +"""Test the ntfy config flow.""" + +from datetime import datetime +from typing import Any +from unittest.mock import AsyncMock + +from aiontfy import AccountTokenResponse +from aiontfy.exceptions import ( + NtfyException, + NtfyHTTPError, + NtfyUnauthorizedAuthenticationError, +) +import pytest + +from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN, SECTION_AUTH +from homeassistant.config_entries import SOURCE_USER, ConfigSubentry +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("user_input", "entry_data"), + [ + ( + { + CONF_URL: "https://ntfy.sh", + CONF_VERIFY_SSL: True, + SECTION_AUTH: {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + }, + { + CONF_URL: "https://ntfy.sh/", + CONF_VERIFY_SSL: True, + CONF_USERNAME: "username", + CONF_TOKEN: "token", + }, + ), + ( + {CONF_URL: "https://ntfy.sh", CONF_VERIFY_SSL: True, SECTION_AUTH: {}}, + { + CONF_URL: "https://ntfy.sh/", + CONF_VERIFY_SSL: True, + CONF_USERNAME: None, + CONF_TOKEN: "token", + }, + ), + ], +) +@pytest.mark.usefixtures("mock_aiontfy") +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + user_input: dict[str, Any], + entry_data: dict[str, Any], +) -> None: + """Test we get the form.""" + 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"], + user_input, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "ntfy.sh" + assert result["data"] == entry_data + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + NtfyHTTPError(418001, 418, "I'm a teapot", ""), + "cannot_connect", + ), + ( + NtfyUnauthorizedAuthenticationError( + 40101, + 401, + "unauthorized", + "https://ntfy.sh/docs/publish/#authentication", + ), + "invalid_auth", + ), + (NtfyException, "cannot_connect"), + (TypeError, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_aiontfy: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + mock_aiontfy.account.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://ntfy.sh", + CONF_VERIFY_SSL: True, + SECTION_AUTH: {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_aiontfy.account.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://ntfy.sh", + CONF_VERIFY_SSL: True, + SECTION_AUTH: {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "ntfy.sh" + assert result["data"] == { + CONF_URL: "https://ntfy.sh/", + CONF_VERIFY_SSL: True, + CONF_USERNAME: "username", + CONF_TOKEN: "token", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_form_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort when entry is already configured.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_URL: "https://ntfy.sh", + CONF_VERIFY_SSL: True, + SECTION_AUTH: {}, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_add_topic_flow(hass: HomeAssistant) -> None: + """Test add topic subentry flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "https://ntfy.sh/", CONF_VERIFY_SSL: True, CONF_USERNAME: None}, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "topic"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.MENU + assert "add_topic" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "add_topic"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_topic" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "mytopic"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={CONF_TOPIC: "mytopic"}, + subentry_id=subentry_id, + subentry_type="topic", + title="mytopic", + unique_id="mytopic", + ) + } + + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_generated_topic(hass: HomeAssistant, mock_random: AsyncMock) -> None: + """Test add topic subentry flow with generated topic name.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "https://ntfy.sh/", CONF_VERIFY_SSL: True}, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "topic"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.MENU + assert "generate_topic" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "generate_topic"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_topic" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: ""}, + ) + + mock_random.assert_called_once() + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "randomtopic", CONF_NAME: "mytopic"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={CONF_TOPIC: "randomtopic", CONF_NAME: "mytopic"}, + subentry_id=subentry_id, + subentry_type="topic", + title="mytopic", + unique_id="randomtopic", + ) + } + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_invalid_topic(hass: HomeAssistant, mock_random: AsyncMock) -> None: + """Test add topic subentry flow with invalid topic name.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "https://ntfy.sh/", CONF_VERIFY_SSL: True}, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "topic"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.MENU + assert "add_topic" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "add_topic"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_topic" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "invalid,topic"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_topic"} + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "mytopic"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={CONF_TOPIC: "mytopic"}, + subentry_id=subentry_id, + subentry_type="topic", + title="mytopic", + unique_id="mytopic", + ) + } + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_topic_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort when entry is already configured.""" + + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "topic"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.MENU + assert "add_topic" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "add_topic"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_topic" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "mytopic"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "user_input", [{CONF_PASSWORD: "password"}, {CONF_TOKEN: "newtoken"}] +) +@pytest.mark.usefixtures("mock_aiontfy") +async def test_flow_reauth( + hass: HomeAssistant, + mock_aiontfy: AsyncMock, + user_input: dict[str, Any], +) -> None: + """Test reauth flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: "username", + CONF_TOKEN: "token", + }, + ) + mock_aiontfy.generate_token.return_value = AccountTokenResponse( + token="newtoken", last_access=datetime.now() + ) + 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, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data[CONF_TOKEN] == "newtoken" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + NtfyHTTPError(418001, 418, "I'm a teapot", ""), + "cannot_connect", + ), + ( + NtfyUnauthorizedAuthenticationError( + 40101, + 401, + "unauthorized", + "https://ntfy.sh/docs/publish/#authentication", + ), + "invalid_auth", + ), + (NtfyException, "cannot_connect"), + (TypeError, "unknown"), + ], +) +async def test_form_reauth_errors( + hass: HomeAssistant, + mock_aiontfy: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test reauth flow errors.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: "username", + CONF_TOKEN: "token", + }, + ) + mock_aiontfy.account.side_effect = exception + mock_aiontfy.generate_token.return_value = AccountTokenResponse( + token="newtoken", last_access=datetime.now() + ) + 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"], {CONF_PASSWORD: "password"} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_aiontfy.account.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "password"} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data == { + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: "username", + CONF_TOKEN: "newtoken", + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_flow_reauth_account_mismatch( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + + 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"], + {CONF_TOKEN: "newtoken"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "account_mismatch" diff --git a/tests/components/ntfy/test_diagnostics.py b/tests/components/ntfy/test_diagnostics.py new file mode 100644 index 00000000000..a4aa3ee6aa7 --- /dev/null +++ b/tests/components/ntfy/test_diagnostics.py @@ -0,0 +1,55 @@ +"""Tests for ntfy diagnostics.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ntfy.const import DOMAIN +from homeassistant.const import CONF_URL +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("mock_aiontfy") +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 + ) + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_diagnostics_redacted_url( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics redacted URL.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="mydomain", + data={ + CONF_URL: "http://mydomain/", + }, + entry_id="123456789", + subentries_data=[], + ) + 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/ntfy/test_init.py b/tests/components/ntfy/test_init.py new file mode 100644 index 00000000000..b80badd8581 --- /dev/null +++ b/tests/components/ntfy/test_init.py @@ -0,0 +1,67 @@ +"""Tests for the ntfy integration.""" + +from unittest.mock import AsyncMock + +from aiontfy.exceptions import ( + NtfyConnectionError, + NtfyHTTPError, + NtfyTimeoutError, + NtfyUnauthorizedAuthenticationError, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_entry_setup_unload( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test integration setup and unload.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("exception", "state"), + [ + ( + NtfyUnauthorizedAuthenticationError( + 40101, + 401, + "unauthorized", + "https://ntfy.sh/docs/publish/#authentication", + ), + ConfigEntryState.SETUP_ERROR, + ), + (NtfyHTTPError(418001, 418, "I'm a teapot", ""), ConfigEntryState.SETUP_RETRY), + (NtfyConnectionError, ConfigEntryState.SETUP_RETRY), + (NtfyTimeoutError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test config entry not ready.""" + + mock_aiontfy.account.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() + + assert config_entry.state is state diff --git a/tests/components/ntfy/test_notify.py b/tests/components/ntfy/test_notify.py new file mode 100644 index 00000000000..76bf1049ae8 --- /dev/null +++ b/tests/components/ntfy/test_notify.py @@ -0,0 +1,137 @@ +"""Tests for the ntfy notify platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import patch + +from aiontfy import Message +from aiontfy.exceptions import NtfyException, NtfyHTTPError +from freezegun.api import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.notify import ( + ATTR_MESSAGE, + ATTR_TITLE, + DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import AsyncMock, MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def notify_only() -> AsyncGenerator[None]: + """Enable only the notify platform.""" + with patch( + "homeassistant.components.ntfy.PLATFORMS", + [Platform.NOTIFY], + ): + yield + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_notify_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the ntfy notify platform.""" + + 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 + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@freeze_time("2025-01-09T12:00:00+00:00") +async def test_send_message( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, +) -> None: + """Test publishing ntfy message.""" + + 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 + + state = hass.states.get("notify.mytopic") + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.mytopic", + ATTR_MESSAGE: "triggered", + ATTR_TITLE: "test", + }, + blocking=True, + ) + + state = hass.states.get("notify.mytopic") + assert state + assert state.state == "2025-01-09T12:00:00+00:00" + + mock_aiontfy.publish.assert_called_once_with( + Message(topic="mytopic", message="triggered", title="test") + ) + + +@pytest.mark.parametrize( + ("exception", "error_msg"), + [ + ( + NtfyHTTPError(41801, 418, "I'm a teapot", ""), + "Failed to publish notification: I'm a teapot", + ), + ( + NtfyException, + "Failed to publish notification due to a connection error", + ), + ], +) +async def test_send_message_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + exception: Exception, + error_msg: str, +) -> None: + """Test publish message exceptions.""" + + 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 + + mock_aiontfy.publish.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error_msg): + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.mytopic", + ATTR_MESSAGE: "triggered", + ATTR_TITLE: "test", + }, + blocking=True, + ) + + mock_aiontfy.publish.assert_called_once_with( + Message(topic="mytopic", message="triggered", title="test") + ) diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index ed9c87f2f90..6e308e22faa 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -5,7 +5,8 @@ from unittest.mock import patch from aionut import NUTError, NUTLoginError -from homeassistant import config_entries, setup +from homeassistant import config_entries +from homeassistant.components.nut.config_flow import PASSWORD_NOT_CHANGED from homeassistant.components.nut.const import DOMAIN from homeassistant.const import ( CONF_ALIAS, @@ -14,14 +15,13 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_RESOURCES, - CONF_SCAN_INTERVAL, CONF_USERNAME, ) 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 +from .util import _get_mock_nutclient, async_init_integration from tests.common import MockConfigEntry @@ -84,9 +84,8 @@ async def test_form_zeroconf(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_user_one_ups(hass: HomeAssistant) -> None: - """Test we get the form.""" - await setup.async_setup_component(hass, "persistent_notification", {}) +async def test_form_user_one_alias(hass: HomeAssistant) -> None: + """Test we can configure a device with one alias.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -129,10 +128,8 @@ async def test_form_user_one_ups(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_user_multiple_ups(hass: HomeAssistant) -> None: - """Test we get the form.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - +async def test_form_user_multiple_aliases(hass: HomeAssistant) -> None: + """Test we can configure device with multiple aliases.""" config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "2.2.2.2", CONF_PORT: 123, CONF_RESOURCES: ["battery.charge"]}, @@ -195,14 +192,13 @@ async def test_form_user_multiple_ups(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 2 -async def test_form_user_one_ups_with_ignored_entry(hass: HomeAssistant) -> None: +async def test_form_user_one_alias_with_ignored_entry(hass: HomeAssistant) -> None: """Test we can setup a new one when there is an ignored one.""" ignored_entry = MockConfigEntry( domain=DOMAIN, data={}, source=config_entries.SOURCE_IGNORE ) ignored_entry.add_to_hass(hass) - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -245,8 +241,8 @@ async def test_form_user_one_ups_with_ignored_entry(hass: HomeAssistant) -> None assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_no_upses_found(hass: HomeAssistant) -> None: - """Test we abort when the NUT server has not UPSes.""" +async def test_form_no_aliases_found(hass: HomeAssistant) -> None: + """Test we abort when the NUT server has no aliases.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -525,6 +521,104 @@ async def test_abort_if_already_setup(hass: HomeAssistant) -> None: assert result2["reason"] == "already_configured" +async def test_abort_duplicate_unique_ids(hass: HomeAssistant) -> None: + """Test we abort if unique_id is already setup.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + await async_init_integration( + hass, + list_ups={"ups1": "UPS 1"}, + list_vars=list_vars, + ) + + mock_pynut = _get_mock_nutclient(list_ups={"ups2": "UPS 2"}, list_vars=list_vars) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 2222, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_abort_multiple_aliases_duplicate_unique_ids(hass: HomeAssistant) -> None: + """Test we abort on multiple aliases if unique_id is already setup.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + + mock_pynut = _get_mock_nutclient( + list_ups={"ups2": "UPS 2", "ups3": "UPS 3"}, list_vars=list_vars + ) + + 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.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 2222, + }, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "ups" + assert result2["type"] is FlowResultType.FORM + + await async_init_integration( + hass, + list_ups={"ups1": "UPS 1"}, + list_vars=list_vars, + ) + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ALIAS: "ups2"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "already_configured" + + async def test_abort_if_already_setup_alias(hass: HomeAssistant) -> None: """Test we abort if component is already setup with same alias.""" config_entry = MockConfigEntry( @@ -575,43 +669,760 @@ async def test_abort_if_already_setup_alias(hass: HomeAssistant) -> None: assert result3["reason"] == "already_configured" -async def test_options_flow(hass: HomeAssistant) -> None: - """Test config flow options.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id="abcde12345", - data=VALID_CONFIG, +async def test_reconfigure_one_alias_successful(hass: HomeAssistant) -> None: + """Test reconfigure one alias successful.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, ) - config_entry.add_to_hass(hass) - with patch("homeassistant.components.nut.async_setup_entry", return_value=True): - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await entry.start_reconfigure_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={} + mock_pynut = _get_mock_nutclient( + list_vars={"battery.voltage": "voltage"}, + list_ups={"ups1": "UPS 1"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == { - CONF_SCAN_INTERVAL: 60, - } + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" - with patch("homeassistant.components.nut.async_setup_entry", return_value=True): - result2 = await hass.config_entries.options.async_init(config_entry.entry_id) + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-new-password" + + +async def test_reconfigure_one_alias_nochange(hass: HomeAssistant) -> None: + """Test reconfigure one alias when there is no change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: int(entry.data[CONF_PORT]), + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + + +async def test_reconfigure_one_alias_password_nochange(hass: HomeAssistant) -> None: + """Test reconfigure one alias when there is no password change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_vars={"battery.voltage": "voltage"}, + list_ups={"ups1": "UPS 1"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: PASSWORD_NOT_CHANGED, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-password" + + +async def test_reconfigure_one_alias_already_configured(hass: HomeAssistant) -> None: + """Test reconfigure when config changed to an existing host/port/alias.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: int(entry.data[CONF_PORT]), + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + + assert entry2.data[CONF_HOST] == "2.2.2.2" + assert entry2.data[CONF_PORT] == 456 + assert entry2.data[CONF_USERNAME] == "test-username" + assert entry2.data[CONF_PASSWORD] == "test-password" + + +async def test_reconfigure_one_alias_unique_id_change(hass: HomeAssistant) -> None: + """Test reconfigure when the unique ID is changed.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={ + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + }, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unique_id_mismatch" + + +async def test_reconfigure_one_alias_duplicate_unique_ids(hass: HomeAssistant) -> None: + """Test reconfigure that results in a duplicate unique ID.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + + await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars=list_vars, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + username="test-username", + password="test-password", + list_ups={"ups2": "UPS 2"}, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups2": "UPS 2"}, + list_vars=list_vars, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "3.3.3.3", + CONF_PORT: 789, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unique_id_mismatch" + + +async def test_reconfigure_multiple_aliases_successful(hass: HomeAssistant) -> None: + """Test reconfigure with multiple aliases is successful.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, + ) assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "init" + assert result2["step_id"] == "reconfigure_ups" - result2 = await hass.config_entries.options.async_configure( + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - user_input={CONF_SCAN_INTERVAL: 12}, + {CONF_ALIAS: "ups2"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-new-password" + assert entry.data[CONF_ALIAS] == "ups2" + + +async def test_reconfigure_multiple_aliases_nochange(hass: HomeAssistant) -> None: + """Test reconfigure with multiple aliases and no change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == { - CONF_SCAN_INTERVAL: 12, - } + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: "ups1"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + assert entry.data[CONF_ALIAS] == "ups1" + + +async def test_reconfigure_multiple_aliases_password_nochange( + hass: HomeAssistant, +) -> None: + """Test reconfigure with multiple aliases when no password change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: PASSWORD_NOT_CHANGED, + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: "ups2"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-password" + assert entry.data[CONF_ALIAS] == "ups2" + + +async def test_reconfigure_multiple_aliases_already_configured( + hass: HomeAssistant, +) -> None: + """Test reconfigure multi aliases changed to existing host/port/alias.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + alias="ups1", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1", "ups2": "UPS 2"}, + list_vars={"battery.voltage": "voltage"}, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + alias="ups2", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + assert entry2.data[CONF_HOST] == "2.2.2.2" + assert entry2.data[CONF_PORT] == 456 + assert entry2.data[CONF_USERNAME] == "test-username" + assert entry2.data[CONF_PASSWORD] == "test-password" + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: entry.data[CONF_ALIAS]}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "already_configured" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + assert entry.data[CONF_ALIAS] == "ups1" + + assert entry2.data[CONF_HOST] == "2.2.2.2" + assert entry2.data[CONF_PORT] == 456 + assert entry2.data[CONF_USERNAME] == "test-username" + assert entry2.data[CONF_PASSWORD] == "test-password" + assert entry2.data[CONF_ALIAS] == "ups2" + + +async def test_reconfigure_multiple_aliases_unique_id_change( + hass: HomeAssistant, +) -> None: + """Test reconfigure with multiple aliases and the unique ID is changed.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + alias="ups1", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1", "ups2": "UPS 2"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: entry.data[CONF_ALIAS]}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "unique_id_mismatch" + + +async def test_reconfigure_multiple_aliases_duplicate_unique_ids( + hass: HomeAssistant, +) -> None: + """Test reconfigure multi aliases that results in duplicate unique ID.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + alias="ups1", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1", "ups2": "UPS 2"}, + list_vars=list_vars, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + alias="ups2", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars=list_vars, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "3.3.3.3", + CONF_PORT: 789, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: entry.data[CONF_ALIAS]}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "unique_id_mismatch" diff --git a/tests/components/nut/test_device_action.py b/tests/components/nut/test_device_action.py index 01675f928e3..3f48d073f9f 100644 --- a/tests/components/nut/test_device_action.py +++ b/tests/components/nut/test_device_action.py @@ -15,12 +15,13 @@ from homeassistant.components.nut import DOMAIN from homeassistant.components.nut.const import INTEGRATION_SUPPORTED_COMMANDS from homeassistant.const import CONF_DEVICE_ID, CONF_TYPE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from .util import async_init_integration -from tests.common import async_get_device_automations +from tests.common import MockConfigEntry, async_get_device_automations async def test_get_all_actions_for_specified_user( @@ -78,10 +79,10 @@ async def test_no_actions_for_anonymous_user( assert len(actions) == 0 -async def test_no_actions_invalid_device( +async def test_no_actions_device_not_found( hass: HomeAssistant, ) -> None: - """Test we get no actions for an invalid device.""" + """Test we get no actions for a device that cannot be found.""" list_commands_return_value = {"beeper.enable": None} await async_init_integration( hass, @@ -98,6 +99,30 @@ async def test_no_actions_invalid_device( assert len(actions) == 0 +async def test_no_actions_device_invalid( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test we get no actions for a device that is invalid.""" + list_commands_return_value = {"beeper.enable": None} + entry = await async_init_integration( + hass, + list_vars={"ups.status": "OL"}, + list_commands_return_value=list_commands_return_value, + ) + device_entry = next(device for device in device_registry.devices.values()) + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + platform = await device_automation.async_get_device_automation_platform( + hass, DOMAIN, DeviceAutomationType.ACTION + ) + actions = await platform.async_get_actions(hass, device_entry.id) + + assert len(actions) == 0 + + async def test_list_commands_exception( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: @@ -191,52 +216,43 @@ async def test_action(hass: HomeAssistant, device_registry: dr.DeviceRegistry) - run_command.assert_called_with("someUps", "beeper.disable") -async def test_rund_command_exception( +async def test_run_command_exception( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - caplog: pytest.LogCaptureFixture, ) -> None: - """Test logged error if run command raises exception.""" + """Test if run command raises exception with translation.""" - list_commands_return_value = {"beeper.enable": None} - error_message = "Something wrong happened" - run_command = AsyncMock(side_effect=NUTError(error_message)) + command_name = "beeper.enable" + nut_error_message = "Something wrong happened" + run_command = AsyncMock(side_effect=NUTError(nut_error_message)) await async_init_integration( hass, list_vars={"ups.status": "OL"}, - list_commands_return_value=list_commands_return_value, + list_ups={"ups1": "UPS 1"}, + list_commands_return_value={command_name: None}, run_command=run_command, ) device_entry = next(device for device in device_registry.devices.values()) - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: [ - { - "trigger": { - "platform": "event", - "event_type": "test_some_event", - }, - "action": { - "domain": DOMAIN, - "device_id": device_entry.id, - "type": "beeper_enable", - }, - }, - ] - }, + platform = await device_automation.async_get_device_automation_platform( + hass, DOMAIN, DeviceAutomationType.ACTION ) - hass.bus.async_fire("test_some_event") - await hass.async_block_till_done() - - assert error_message in caplog.text + error_message = f"Error running command {command_name}, {nut_error_message}" + with pytest.raises(HomeAssistantError, match=error_message): + await platform.async_call_action_from_config( + hass, + { + CONF_TYPE: command_name, + CONF_DEVICE_ID: device_entry.id, + }, + {}, + None, + ) -async def test_action_exception_invalid_device(hass: HomeAssistant) -> None: - """Test raises exception if invalid device.""" +async def test_action_exception_device_not_found(hass: HomeAssistant) -> None: + """Test raises exception if device not found.""" list_commands_return_value = {"beeper.enable": None} await async_init_integration( hass, @@ -248,10 +264,73 @@ async def test_action_exception_invalid_device(hass: HomeAssistant) -> None: hass, DOMAIN, DeviceAutomationType.ACTION ) - with pytest.raises(InvalidDeviceAutomationConfig): + device_id = "invalid_device_id" + error_message = f"Unable to find a NUT device with ID {device_id}" + with pytest.raises(InvalidDeviceAutomationConfig, match=error_message): await platform.async_call_action_from_config( hass, - {CONF_TYPE: "beeper.enable", CONF_DEVICE_ID: "invalid_device_id"}, + {CONF_TYPE: "beeper.enable", CONF_DEVICE_ID: device_id}, + {}, + None, + ) + + +async def test_action_exception_invalid_config( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test raises exception if no NUT config entry found.""" + + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "mock-identifier")}, + ) + + platform = await device_automation.async_get_device_automation_platform( + hass, DOMAIN, DeviceAutomationType.ACTION + ) + + with pytest.raises(InvalidDeviceAutomationConfig): + await platform.async_call_action_from_config( + hass, + {CONF_TYPE: "beeper.enable", CONF_DEVICE_ID: device_entry.id}, + {}, + None, + ) + + +async def test_action_exception_device_invalid( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test raises exception if config entry for device is invalid.""" + list_commands_return_value = {"beeper.enable": None} + entry = await async_init_integration( + hass, + list_vars={"ups.status": "OL"}, + list_commands_return_value=list_commands_return_value, + ) + device_entry = next(device for device in device_registry.devices.values()) + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + platform = await device_automation.async_get_device_automation_platform( + hass, DOMAIN, DeviceAutomationType.ACTION + ) + + error_message = ( + f"Invalid configuration entries for NUT device with ID {device_entry.id}" + ) + with pytest.raises(InvalidDeviceAutomationConfig, match=error_message): + await platform.async_call_action_from_config( + hass, + {CONF_TYPE: "beeper.enable", CONF_DEVICE_ID: device_entry.id}, {}, None, ) diff --git a/tests/components/nut/test_init.py b/tests/components/nut/test_init.py index 0585696cef2..6f1fb94478d 100644 --- a/tests/components/nut/test_init.py +++ b/tests/components/nut/test_init.py @@ -4,6 +4,7 @@ from copy import deepcopy from unittest.mock import patch from aionut import NUTError, NUTLoginError +import pytest from homeassistant.components.nut.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -11,15 +12,44 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, + CONF_SCAN_INTERVAL, CONF_USERNAME, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component from .util import _get_mock_nutclient, async_init_integration from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + + +async def test_config_entry_migrations(hass: HomeAssistant) -> None: + """Test that config entries were migrated.""" + mock_pynut = _get_mock_nutclient( + list_vars={"battery.voltage": "voltage"}, + list_ups={"ups1": "UPS 1"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 123, + }, + options={CONF_SCAN_INTERVAL: 30}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + + assert CONF_SCAN_INTERVAL not in entry.options async def test_async_setup_entry(hass: HomeAssistant) -> None: @@ -56,60 +86,16 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None: assert not hass.data.get(DOMAIN) -async def test_config_not_ready(hass: HomeAssistant) -> None: - """Test for setup failure if connection to broker is missing.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "mock", CONF_PORT: "mock"}, - ) - entry.add_to_hass(hass) +async def test_remove_device_valid( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that we cannot remove a device that still exists.""" + assert await async_setup_component(hass, "config", {}) - with ( - patch( - "homeassistant.components.nut.AIONUTClient.list_ups", - return_value={"ups1"}, - ), - patch( - "homeassistant.components.nut.AIONUTClient.list_vars", - side_effect=NUTError, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_auth_fails(hass: HomeAssistant) -> None: - """Test for setup failure if auth has changed.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "mock", CONF_PORT: "mock"}, - ) - entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.components.nut.AIONUTClient.list_ups", - return_value={"ups1"}, - ), - patch( - "homeassistant.components.nut.AIONUTClient.list_vars", - side_effect=NUTLoginError, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_ERROR - - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["context"]["source"] == "reauth" - - -async def test_serial_number(hass: HomeAssistant) -> None: - """Test for serial number set on device.""" mock_serial_number = "A00000000000" - await async_init_integration( + config_entry = await async_init_integration( hass, username="someuser", password="somepassword", @@ -128,8 +114,141 @@ async def test_serial_number(hass: HomeAssistant) -> None: assert device_entry is not None assert device_entry.serial_number == mock_serial_number + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert not response["success"] -async def test_device_location(hass: HomeAssistant) -> None: + +async def test_remove_device_stale( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that we can remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + + mock_serial_number = "A00000000000" + config_entry = await async_init_integration( + hass, + username="someuser", + password="somepassword", + list_vars={"ups.serial": mock_serial_number}, + list_ups={"ups1": "UPS 1"}, + list_commands_return_value=[], + ) + + device_registry = dr.async_get(hass) + assert device_registry is not None + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "remove-device-id")}, + ) + + assert device_entry is not None + + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert response["success"] + + # Verify that device entry is removed + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "remove-device-id")} + ) + assert device_entry is None + + +async def test_config_not_ready( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test for setup failure if connection to broker is missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "mock", CONF_PORT: "mock"}, + ) + entry.add_to_hass(hass) + + nut_error_message = "Something wrong happened" + error_message = f"Error fetching UPS state: {nut_error_message}" + with ( + patch( + "homeassistant.components.nut.AIONUTClient.list_ups", + return_value={"ups1"}, + ), + patch( + "homeassistant.components.nut.AIONUTClient.list_vars", + side_effect=NUTError(nut_error_message), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.SETUP_RETRY + + assert error_message in caplog.text + + +async def test_auth_fails( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test for setup failure if auth has changed.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "mock", CONF_PORT: "mock"}, + ) + entry.add_to_hass(hass) + + nut_error_message = "Something wrong happened" + error_message = f"Device authentication error: {nut_error_message}" + with ( + patch( + "homeassistant.components.nut.AIONUTClient.list_ups", + return_value={"ups1"}, + ), + patch( + "homeassistant.components.nut.AIONUTClient.list_vars", + side_effect=NUTLoginError(nut_error_message), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.SETUP_ERROR + + assert error_message in caplog.text + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == "reauth" + + +async def test_serial_number( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test for serial number set on device.""" + mock_serial_number = "A00000000000" + await async_init_integration( + hass, + username="someuser", + password="somepassword", + list_vars={"ups.serial": mock_serial_number}, + list_ups={"ups1": "UPS 1"}, + list_commands_return_value=[], + ) + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_serial_number)} + ) + + assert device_entry is not None + assert device_entry.serial_number == mock_serial_number + + +async def test_device_location( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: """Test for suggested location on device.""" mock_serial_number = "A00000000000" mock_device_location = "XYZ Location" @@ -145,9 +264,6 @@ async def test_device_location(hass: HomeAssistant) -> None: list_commands_return_value=[], ) - device_registry = dr.async_get(hass) - assert device_registry is not None - device_entry = device_registry.async_get_device( identifiers={(DOMAIN, mock_serial_number)} ) diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index cdec6c5083b..db9028222b1 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -7,16 +7,20 @@ import pytest from homeassistant.components.nut.const import DOMAIN from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, CONF_HOST, CONF_PORT, CONF_RESOURCES, PERCENTAGE, STATE_UNKNOWN, + Platform, UnitOfElectricCurrent, UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, translation from .util import ( _get_mock_nutclient, @@ -53,9 +57,9 @@ async def test_ups_devices( assert state.state == "100" expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, + ATTR_DEVICE_CLASS: "battery", + ATTR_FRIENDLY_NAME: "Ups1 Battery charge", + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -88,9 +92,9 @@ async def test_ups_devices_with_unique_ids( assert state.state == "100" expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, + ATTR_DEVICE_CLASS: "battery", + ATTR_FRIENDLY_NAME: "Ups1 Battery charge", + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -121,41 +125,38 @@ async def test_pdu_devices_with_unique_ids( _test_sensor_and_attributes( hass, entity_registry, - model, unique_id=f"{unique_id_base}_input.voltage", device_id="sensor.ups1_input_voltage", state_value="122.91", expected_attributes={ - "device_class": SensorDeviceClass.VOLTAGE, + ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, "state_class": SensorStateClass.MEASUREMENT, - "friendly_name": "Ups1 Input voltage", - "unit_of_measurement": UnitOfElectricPotential.VOLT, + ATTR_FRIENDLY_NAME: "Ups1 Input voltage", + ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricPotential.VOLT, }, ) _test_sensor_and_attributes( hass, entity_registry, - model, unique_id=f"{unique_id_base}_ambient.humidity.status", device_id="sensor.ups1_ambient_humidity_status", state_value="good", expected_attributes={ - "device_class": SensorDeviceClass.ENUM, - "friendly_name": "Ups1 Ambient humidity status", + ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, + ATTR_FRIENDLY_NAME: "Ups1 Ambient humidity status", }, ) _test_sensor_and_attributes( hass, entity_registry, - model, unique_id=f"{unique_id_base}_ambient.temperature.status", device_id="sensor.ups1_ambient_temperature_status", state_value="good", expected_attributes={ - "device_class": SensorDeviceClass.ENUM, - "friendly_name": "Ups1 Ambient temperature status", + ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, + ATTR_FRIENDLY_NAME: "Ups1 Ambient temperature status", }, ) @@ -246,6 +247,36 @@ async def test_stale_options( assert state.state == "10" +async def test_state_ambient_translation(hass: HomeAssistant) -> None: + """Test translation of ambient state sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "mock", CONF_PORT: "mock"}, + ) + entry.add_to_hass(hass) + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, list_vars={"ambient.humidity.status": "good"} + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + key = "ambient_humidity_status" + state = hass.states.get(f"sensor.ups1_{key}") + assert state.state == "good" + + result = translation.async_translate_state( + hass, state.state, Platform.SENSOR, DOMAIN, key, None + ) + + assert result == "Good" + + @pytest.mark.parametrize( ("model", "unique_id_base"), [ @@ -300,28 +331,26 @@ async def test_pdu_dynamic_outlets( _test_sensor_and_attributes( hass, entity_registry, - model, unique_id=f"{unique_id_base}_outlet.1.current", device_id="sensor.ups1_outlet_a1_current", state_value="0", expected_attributes={ - "device_class": SensorDeviceClass.CURRENT, - "friendly_name": "Ups1 Outlet A1 current", - "unit_of_measurement": UnitOfElectricCurrent.AMPERE, + ATTR_DEVICE_CLASS: SensorDeviceClass.CURRENT, + ATTR_FRIENDLY_NAME: "Ups1 Outlet A1 current", + ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricCurrent.AMPERE, }, ) _test_sensor_and_attributes( hass, entity_registry, - model, unique_id=f"{unique_id_base}_outlet.24.current", device_id="sensor.ups1_outlet_a24_current", state_value="0.19", expected_attributes={ - "device_class": SensorDeviceClass.CURRENT, - "friendly_name": "Ups1 Outlet A24 current", - "unit_of_measurement": UnitOfElectricCurrent.AMPERE, + ATTR_DEVICE_CLASS: SensorDeviceClass.CURRENT, + ATTR_FRIENDLY_NAME: "Ups1 Outlet A24 current", + ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricCurrent.AMPERE, }, ) diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py index 07c073f0286..49510fc9d72 100644 --- a/tests/components/nut/util.py +++ b/tests/components/nut/util.py @@ -1,10 +1,17 @@ """Tests for the nut integration.""" import json +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from homeassistant.components.nut.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_ALIAS, + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -35,8 +42,11 @@ def _get_mock_nutclient( async def async_init_integration( hass: HomeAssistant, ups_fixture: str | None = None, + host: str = "mock", + port: int = 1234, username: str = "mock", password: str = "mock", + alias: str | None = None, list_ups: dict[str, str] | None = None, list_vars: dict[str, str] | None = None, list_commands_return_value: dict[str, str] | None = None, @@ -65,15 +75,24 @@ async def async_init_integration( "homeassistant.components.nut.AIONUTClient", return_value=mock_pynut, ): + extra_config_entry_data: dict[str, Any] = {} + + if alias is not None: + extra_config_entry_data = { + CONF_ALIAS: alias, + } + entry = MockConfigEntry( domain=DOMAIN, data={ - CONF_HOST: "mock", + CONF_HOST: host, CONF_PASSWORD: password, - CONF_PORT: "mock", + CONF_PORT: port, CONF_USERNAME: username, - }, + } + | extra_config_entry_data, ) + entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -85,7 +104,6 @@ async def async_init_integration( def _test_sensor_and_attributes( hass: HomeAssistant, entity_registry: er.EntityRegistry, - model: str, unique_id: str, device_id: str, state_value: str, diff --git a/tests/components/ohme/test_sensor.py b/tests/components/ohme/test_sensor.py index 21f9f06f963..8fc9edddcf9 100644 --- a/tests/components/ohme/test_sensor.py +++ b/tests/components/ohme/test_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory from ohme import ApiException +import pytest from syrupy import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform @@ -16,6 +17,7 @@ from . import setup_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 509dece7dd0..08acdc94afc 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -3,32 +3,27 @@ import asyncio from collections.abc import AsyncGenerator from http import HTTPStatus -from io import StringIO import os from typing import Any -from unittest.mock import ANY, DEFAULT, AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, Mock, patch -from hass_nabucasa.auth import CognitoAuth -from hass_nabucasa.const import STATE_CONNECTED -from hass_nabucasa.iot import CloudIoT import pytest -from syrupy import SnapshotAssertion -from homeassistant.components import backup, onboarding -from homeassistant.components.cloud import DOMAIN as CLOUD_DOMAIN, CloudClient +from homeassistant.components import onboarding from homeassistant.components.onboarding import const, views from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar -from homeassistant.helpers.backup import async_initialize_backup -from homeassistant.setup import async_setup_component +from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component from . import mock_storage from tests.common import ( CLIENT_ID, CLIENT_REDIRECT_URI, + MockModule, MockUser, + mock_integration, + mock_platform, register_auth_provider, ) from tests.test_util.aiohttp import AiohttpClientMocker @@ -36,11 +31,9 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) -def auth_active(hass: HomeAssistant) -> None: +async def auth_active(hass: HomeAssistant) -> None: """Ensure auth is always active.""" - hass.loop.run_until_complete( - register_auth_provider(hass, {"type": "homeassistant"}) - ) + await register_auth_provider(hass, {"type": "homeassistant"}) @pytest.fixture(name="rpi") @@ -635,13 +628,6 @@ async def test_onboarding_installation_type( ("method", "view", "kwargs"), [ ("get", "installation_type", {}), - ("get", "backup/info", {}), - ( - "post", - "backup/restore", - {"json": {"backup_id": "abc123", "agent_id": "test"}}, - ), - ("post", "backup/upload", {}), ], ) async def test_onboarding_view_after_done( @@ -727,483 +713,135 @@ async def test_complete_onboarding( @pytest.mark.parametrize( - ("method", "view", "kwargs"), + ("domain", "expected_result"), [ - ("get", "backup/info", {}), - ( - "post", - "backup/restore", - {"json": {"backup_id": "abc123", "agent_id": "test"}}, - ), - ("post", "backup/upload", {}), + ("onboarding", {"integration_loaded": True}), + ("non_existing_domain", {"integration_loaded": False}), ], ) -async def test_onboarding_backup_view_without_backup( +async def test_wait_integration( hass: HomeAssistant, hass_storage: dict[str, Any], hass_client: ClientSessionGenerator, - method: str, - view: str, - kwargs: dict[str, Any], + domain: str, + expected_result: dict[str, Any], ) -> None: - """Test interacting with backup wievs when backup integration is missing.""" + """Test we can get wait for an integration to load.""" mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() client = await hass_client() - - resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) - - assert resp.status == 500 - assert await resp.json() == {"code": "backup_disabled"} - - -async def test_onboarding_backup_info( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test backup info.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() - - client = await hass_client() - - backups = { - "abc123": backup.ManagerBackup( - addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")], - agents={ - "backup.local": backup.manager.AgentBackupStatus(protected=True, size=0) - }, - backup_id="abc123", - date="1970-01-01T00:00:00.000Z", - database_included=True, - extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, - folders=[backup.Folder.MEDIA, backup.Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - failed_agent_ids=[], - with_automatic_settings=True, - ), - "def456": backup.ManagerBackup( - addons=[], - agents={ - "test.remote": backup.manager.AgentBackupStatus(protected=True, size=0) - }, - backup_id="def456", - date="1980-01-01T00:00:00.000Z", - database_included=False, - extra_metadata={ - "instance_id": "unknown_uuid", - "with_automatic_settings": True, - }, - folders=[backup.Folder.MEDIA, backup.Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test 2", - failed_agent_ids=[], - with_automatic_settings=None, - ), - } - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backups", - return_value=(backups, {}), - ): - resp = await client.get("/api/onboarding/backup/info") - - assert resp.status == 200 - assert await resp.json() == snapshot - - -@pytest.mark.parametrize( - ("params", "expected_kwargs"), - [ - ( - {"backup_id": "abc123", "agent_id": "backup.local"}, - { - "agent_id": "backup.local", - "password": None, - "restore_addons": None, - "restore_database": True, - "restore_folders": None, - "restore_homeassistant": True, - }, - ), - ( - { - "backup_id": "abc123", - "agent_id": "backup.local", - "password": "hunter2", - "restore_addons": ["addon_1"], - "restore_database": True, - "restore_folders": ["media"], - }, - { - "agent_id": "backup.local", - "password": "hunter2", - "restore_addons": ["addon_1"], - "restore_database": True, - "restore_folders": [backup.Folder.MEDIA], - "restore_homeassistant": True, - }, - ), - ( - { - "backup_id": "abc123", - "agent_id": "backup.local", - "password": "hunter2", - "restore_addons": ["addon_1", "addon_2"], - "restore_database": False, - "restore_folders": ["media", "share"], - }, - { - "agent_id": "backup.local", - "password": "hunter2", - "restore_addons": ["addon_1", "addon_2"], - "restore_database": False, - "restore_folders": [backup.Folder.MEDIA, backup.Folder.SHARE], - "restore_homeassistant": True, - }, - ), - ], -) -async def test_onboarding_backup_restore( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - params: dict[str, Any], - expected_kwargs: dict[str, Any], -) -> None: - """Test restore backup.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() - - client = await hass_client() - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_restore_backup", - ) as mock_restore: - resp = await client.post("/api/onboarding/backup/restore", json=params) - assert resp.status == 200 - mock_restore.assert_called_once_with("abc123", **expected_kwargs) - - -@pytest.mark.parametrize( - ("params", "restore_error", "expected_status", "expected_json", "restore_calls"), - [ - # Missing agent_id - ( - {"backup_id": "abc123"}, - None, - 400, - { - "message": "Message format incorrect: required key not provided @ data['agent_id']" - }, - 0, - ), - # Missing backup_id - ( - {"agent_id": "backup.local"}, - None, - 400, - { - "message": "Message format incorrect: required key not provided @ data['backup_id']" - }, - 0, - ), - # Invalid restore_database - ( - { - "backup_id": "abc123", - "agent_id": "backup.local", - "restore_database": "yes_please", - }, - None, - 400, - { - "message": "Message format incorrect: expected bool for dictionary value @ data['restore_database']" - }, - 0, - ), - # Invalid folder - ( - { - "backup_id": "abc123", - "agent_id": "backup.local", - "restore_folders": ["invalid"], - }, - None, - 400, - { - "message": "Message format incorrect: expected Folder or one of 'share', 'addons/local', 'ssl', 'media' @ data['restore_folders'][0]" - }, - 0, - ), - # Wrong password - ( - {"backup_id": "abc123", "agent_id": "backup.local"}, - backup.IncorrectPasswordError, - 400, - {"code": "incorrect_password"}, - 1, - ), - # Home Assistant error - ( - {"backup_id": "abc123", "agent_id": "backup.local"}, - HomeAssistantError("Boom!"), - 400, - {"code": "restore_failed", "message": "Boom!"}, - 1, - ), - ], -) -async def test_onboarding_backup_restore_error( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - params: dict[str, Any], - restore_error: Exception | None, - expected_status: int, - expected_json: str, - restore_calls: int, -) -> None: - """Test restore backup fails.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() - - client = await hass_client() - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_restore_backup", - side_effect=restore_error, - ) as mock_restore: - resp = await client.post("/api/onboarding/backup/restore", json=params) - - assert resp.status == expected_status - assert await resp.json() == expected_json - assert len(mock_restore.mock_calls) == restore_calls - - -@pytest.mark.parametrize( - ("params", "restore_error", "expected_status", "expected_message", "restore_calls"), - [ - # Unexpected error - ( - {"backup_id": "abc123", "agent_id": "backup.local"}, - Exception("Boom!"), - 500, - "500 Internal Server Error", - 1, - ), - ], -) -async def test_onboarding_backup_restore_unexpected_error( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - params: dict[str, Any], - restore_error: Exception | None, - expected_status: int, - expected_message: str, - restore_calls: int, -) -> None: - """Test restore backup fails.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() - - client = await hass_client() - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_restore_backup", - side_effect=restore_error, - ) as mock_restore: - resp = await client.post("/api/onboarding/backup/restore", json=params) - - assert resp.status == expected_status - assert (await resp.content.read()).decode().startswith(expected_message) - assert len(mock_restore.mock_calls) == restore_calls - - -async def test_onboarding_backup_upload( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, -) -> None: - """Test upload backup.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() - - client = await hass_client() - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_receive_backup", - return_value="abc123", - ) as mock_receive: - resp = await client.post( - "/api/onboarding/backup/upload?agent_id=backup.local", - data={"file": StringIO("test")}, - ) - assert resp.status == 201 - assert await resp.json() == {"backup_id": "abc123"} - mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY) - - -@pytest.fixture(name="cloud") -async def cloud_fixture() -> AsyncGenerator[MagicMock]: - """Mock the cloud object. - - See the real hass_nabucasa.Cloud class for how to configure the mock. - """ - with patch( - "homeassistant.components.cloud.Cloud", autospec=True - ) as mock_cloud_class: - mock_cloud = mock_cloud_class.return_value - - mock_cloud.auth = MagicMock(spec=CognitoAuth) - mock_cloud.iot = MagicMock( - spec=CloudIoT, last_disconnect_reason=None, state=STATE_CONNECTED - ) - - def set_up_mock_cloud( - cloud_client: CloudClient, mode: str, **kwargs: Any - ) -> DEFAULT: - """Set up mock cloud with a mock constructor.""" - - # Attributes set in the constructor with parameters. - mock_cloud.client = cloud_client - - return DEFAULT - - mock_cloud_class.side_effect = set_up_mock_cloud - - # Attributes that we mock with default values. - mock_cloud.id_token = None - mock_cloud.is_logged_in = False - - yield mock_cloud - - -@pytest.fixture(name="setup_cloud") -async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: - """Fixture that sets up cloud.""" - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, CLOUD_DOMAIN, {}) - await hass.async_block_till_done() - - -@pytest.mark.usefixtures("setup_cloud") -async def test_onboarding_cloud_forgot_password( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - cloud: MagicMock, -) -> None: - """Test cloud forgot password.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - await hass.async_block_till_done() - - client = await hass_client() - - mock_cognito = cloud.auth - - req = await client.post( - "/api/onboarding/cloud/forgot_password", json={"email": "hello@bla.com"} - ) - - assert req.status == HTTPStatus.OK - assert mock_cognito.async_forgot_password.call_count == 1 - - -@pytest.mark.usefixtures("setup_cloud") -async def test_onboarding_cloud_login( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - cloud: MagicMock, -) -> None: - """Test logging out from cloud.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - await hass.async_block_till_done() - - client = await hass_client() - req = await client.post( - "/api/onboarding/cloud/login", - json={"email": "my_username", "password": "my_password"}, - ) + req = await client.post("/api/onboarding/integration/wait", json={"domain": domain}) assert req.status == HTTPStatus.OK data = await req.json() - assert data == {"cloud_pipeline": None, "success": True} - assert cloud.login.call_count == 1 + assert data == expected_result -@pytest.mark.usefixtures("setup_cloud") -async def test_onboarding_cloud_logout( +async def test_wait_integration_startup( hass: HomeAssistant, hass_storage: dict[str, Any], hass_client: ClientSessionGenerator, - cloud: MagicMock, ) -> None: - """Test logging out from cloud.""" + """Test we can get wait for an integration to load during startup.""" mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() - client = await hass_client() - req = await client.post("/api/onboarding/cloud/logout") + setup_stall = asyncio.Event() + setup_started = asyncio.Event() + + async def mock_setup(hass: HomeAssistant, _) -> bool: + setup_started.set() + await setup_stall.wait() + return True + + mock_integration(hass, MockModule("test", async_setup=mock_setup)) + + # The integration is not loaded, and is also not scheduled to load + req = await client.post("/api/onboarding/integration/wait", json={"domain": "test"}) assert req.status == HTTPStatus.OK data = await req.json() - assert data == {"message": "ok"} - assert cloud.logout.call_count == 1 + assert data == {"integration_loaded": False} + + # Mark the component as scheduled to be loaded + async_set_domains_to_be_loaded(hass, {"test"}) + + # Start loading the component, including its config entries + hass.async_create_task(async_setup_component(hass, "test", {})) + await setup_started.wait() + + # The component is not yet loaded + assert "test" not in hass.config.components + + # Allow setup to proceed + setup_stall.set() + + # The component is scheduled to load, this will block until the config entry is loaded + req = await client.post("/api/onboarding/integration/wait", json={"domain": "test"}) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"integration_loaded": True} + + # The component has been loaded + assert "test" in hass.config.components -@pytest.mark.usefixtures("setup_cloud") -async def test_onboarding_cloud_status( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - cloud: MagicMock, +async def test_not_setup_platform_if_onboarded( + hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: - """Test logging out from cloud.""" - mock_storage(hass_storage, {"done": []}) + """Test if onboarding is done, we don't setup platforms.""" + mock_storage(hass_storage, {"done": onboarding.STEPS}) + + platform_mock = Mock(async_setup_views=AsyncMock(), spec=["async_setup_views"]) + mock_platform(hass, "test.onboarding", platform_mock) + assert await async_setup_component(hass, "test", {}) + await hass.async_block_till_done() assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() - client = await hass_client() - req = await client.get("/api/onboarding/cloud/status") + assert len(platform_mock.async_setup_views.mock_calls) == 0 - assert req.status == HTTPStatus.OK - data = await req.json() - assert data == {"logged_in": False} + +async def test_setup_platform_if_not_onboarded( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test if onboarding is not done, we setup platforms.""" + platform_mock = Mock(async_setup_views=AsyncMock(), spec=["async_setup_views"]) + mock_platform(hass, "test.onboarding", platform_mock) + assert await async_setup_component(hass, "test", {}) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + platform_mock.async_setup_views.assert_awaited_once_with(hass, {"done": []}) + + +@pytest.mark.parametrize( + "platform_mock", + [ + Mock(some_method=AsyncMock(), spec=["some_method"]), + Mock(spec=[]), + ], +) +async def test_bad_platform( + hass: HomeAssistant, + platform_mock: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test loading onboarding platform which doesn't have the expected methods.""" + mock_platform(hass, "test.onboarding", platform_mock) + assert await async_setup_component(hass, "test", {}) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + assert platform_mock.mock_calls == [] + assert "'test.onboarding' is not a valid onboarding platform" in caplog.text diff --git a/tests/components/oncue/__init__.py b/tests/components/oncue/__init__.py index d88774307c0..d7821861e88 100644 --- a/tests/components/oncue/__init__.py +++ b/tests/components/oncue/__init__.py @@ -1,881 +1 @@ """Tests for the Oncue integration.""" - -from contextlib import contextmanager -from unittest.mock import patch - -from aiooncue import LoginFailedException, OncueDevice, OncueSensor - -MOCK_ASYNC_FETCH_ALL = { - "123456": OncueDevice( - name="My Generator", - state="Off", - product_name="RDC 2.4", - hardware_version="319", - serial_number="SERIAL", - sensors={ - "Product": OncueSensor( - name="Product", - display_name="Controller Type", - value="RDC 2.4", - display_value="RDC 2.4", - unit=None, - ), - "FirmwareVersion": OncueSensor( - name="FirmwareVersion", - display_name="Current Firmware", - value="2.0.6", - display_value="2.0.6", - unit=None, - ), - "LatestFirmware": OncueSensor( - name="LatestFirmware", - display_name="Latest Firmware", - value="2.0.6", - display_value="2.0.6", - unit=None, - ), - "EngineSpeed": OncueSensor( - name="EngineSpeed", - display_name="Engine Speed", - value="0", - display_value="0 R/min", - unit="R/min", - ), - "EngineTargetSpeed": OncueSensor( - name="EngineTargetSpeed", - display_name="Engine Target Speed", - value="0", - display_value="0 R/min", - unit="R/min", - ), - "EngineOilPressure": OncueSensor( - name="EngineOilPressure", - display_name="Engine Oil Pressure", - value=0, - display_value="0 Psi", - unit="Psi", - ), - "EngineCoolantTemperature": OncueSensor( - name="EngineCoolantTemperature", - display_name="Engine Coolant Temperature", - value=32, - display_value="32 F", - unit="F", - ), - "BatteryVoltage": OncueSensor( - name="BatteryVoltage", - display_name="Battery Voltage", - value="13.4", - display_value="13.4 V", - unit="V", - ), - "LubeOilTemperature": OncueSensor( - name="LubeOilTemperature", - display_name="Lube Oil Temperature", - value=32, - display_value="32 F", - unit="F", - ), - "GensetControllerTemperature": OncueSensor( - name="GensetControllerTemperature", - display_name="Generator Controller Temperature", - value=84.2, - display_value="84.2 F", - unit="F", - ), - "EngineCompartmentTemperature": OncueSensor( - name="EngineCompartmentTemperature", - display_name="Engine Compartment Temperature", - value=62.6, - display_value="62.6 F", - unit="F", - ), - "GeneratorTrueTotalPower": OncueSensor( - name="GeneratorTrueTotalPower", - display_name="Generator True Total Power", - value="0.0", - display_value="0.0 W", - unit="W", - ), - "GeneratorTruePercentOfRatedPower": OncueSensor( - name="GeneratorTruePercentOfRatedPower", - display_name="Generator True Percent Of Rated Power", - value="0", - display_value="0 %", - unit="%", - ), - "GeneratorVoltageAB": OncueSensor( - name="GeneratorVoltageAB", - display_name="Generator Voltage AB", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "GeneratorVoltageAverageLineToLine": OncueSensor( - name="GeneratorVoltageAverageLineToLine", - display_name="Generator Voltage Average Line To Line", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "GeneratorCurrentAverage": OncueSensor( - name="GeneratorCurrentAverage", - display_name="Generator Current Average", - value="0.0", - display_value="0.0 A", - unit="A", - ), - "GeneratorFrequency": OncueSensor( - name="GeneratorFrequency", - display_name="Generator Frequency", - value="0.0", - display_value="0.0 Hz", - unit="Hz", - ), - "GensetSerialNumber": OncueSensor( - name="GensetSerialNumber", - display_name="Generator Serial Number", - value="33FDGMFR0026", - display_value="33FDGMFR0026", - unit=None, - ), - "GensetState": OncueSensor( - name="GensetState", - display_name="Generator State", - value="Off", - display_value="Off", - unit=None, - ), - "GensetControllerSerialNumber": OncueSensor( - name="GensetControllerSerialNumber", - display_name="Generator Controller Serial Number", - value="-1", - display_value="-1", - unit=None, - ), - "GensetModelNumberSelect": OncueSensor( - name="GensetModelNumberSelect", - display_name="Genset Model Number Select", - value="38 RCLB", - display_value="38 RCLB", - unit=None, - ), - "GensetControllerClockTime": OncueSensor( - name="GensetControllerClockTime", - display_name="Generator Controller Clock Time", - value="2022-01-13 18:08:13", - display_value="2022-01-13 18:08:13", - unit=None, - ), - "GensetControllerTotalOperationTime": OncueSensor( - name="GensetControllerTotalOperationTime", - display_name="Generator Controller Total Operation Time", - value="16770.8", - display_value="16770.8 h", - unit="h", - ), - "EngineTotalRunTime": OncueSensor( - name="EngineTotalRunTime", - display_name="Engine Total Run Time", - value="28.1", - display_value="28.1 h", - unit="h", - ), - "EngineTotalRunTimeLoaded": OncueSensor( - name="EngineTotalRunTimeLoaded", - display_name="Engine Total Run Time Loaded", - value="5.5", - display_value="5.5 h", - unit="h", - ), - "EngineTotalNumberOfStarts": OncueSensor( - name="EngineTotalNumberOfStarts", - display_name="Engine Total Number Of Starts", - value="101", - display_value="101", - unit=None, - ), - "GensetTotalEnergy": OncueSensor( - name="GensetTotalEnergy", - display_name="Genset Total Energy", - value="1.2022309E7", - display_value="1.2022309E7 kWh", - unit="kWh", - ), - "AtsContactorPosition": OncueSensor( - name="AtsContactorPosition", - display_name="Ats Contactor Position", - value="Source1", - display_value="Source1", - unit=None, - ), - "AtsSourcesAvailable": OncueSensor( - name="AtsSourcesAvailable", - display_name="Ats Sources Available", - value="Source1", - display_value="Source1", - unit=None, - ), - "Source1VoltageAverageLineToLine": OncueSensor( - name="Source1VoltageAverageLineToLine", - display_name="Source1 Voltage Average Line To Line", - value="253.5", - display_value="253.5 V", - unit="V", - ), - "Source2VoltageAverageLineToLine": OncueSensor( - name="Source2VoltageAverageLineToLine", - display_name="Source2 Voltage Average Line To Line", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "IPAddress": OncueSensor( - name="IPAddress", - display_name="IP Address", - value="1.2.3.4:1026", - display_value="1.2.3.4:1026", - unit=None, - ), - "MacAddress": OncueSensor( - name="MacAddress", - display_name="Mac Address", - value="221157033710592", - display_value="221157033710592", - unit=None, - ), - "ConnectedServerIPAddress": OncueSensor( - name="ConnectedServerIPAddress", - display_name="Connected Server IP Address", - value="40.117.195.28", - display_value="40.117.195.28", - unit=None, - ), - "NetworkConnectionEstablished": OncueSensor( - name="NetworkConnectionEstablished", - display_name="Network Connection Established", - value="true", - display_value="True", - unit=None, - ), - "SerialNumber": OncueSensor( - name="SerialNumber", - display_name="Serial Number", - value="1073879692", - display_value="1073879692", - unit=None, - ), - }, - ) -} - - -MOCK_ASYNC_FETCH_ALL_OFFLINE_DEVICE = { - "456789": OncueDevice( - name="My Generator", - state="Off", - product_name="RDC 2.4", - hardware_version="319", - serial_number="SERIAL", - sensors={ - "Product": OncueSensor( - name="Product", - display_name="Controller Type", - value="RDC 2.4", - display_value="RDC 2.4", - unit=None, - ), - "FirmwareVersion": OncueSensor( - name="FirmwareVersion", - display_name="Current Firmware", - value="2.0.6", - display_value="2.0.6", - unit=None, - ), - "LatestFirmware": OncueSensor( - name="LatestFirmware", - display_name="Latest Firmware", - value="2.0.6", - display_value="2.0.6", - unit=None, - ), - "EngineSpeed": OncueSensor( - name="EngineSpeed", - display_name="Engine Speed", - value="0", - display_value="0 R/min", - unit="R/min", - ), - "EngineTargetSpeed": OncueSensor( - name="EngineTargetSpeed", - display_name="Engine Target Speed", - value="0", - display_value="0 R/min", - unit="R/min", - ), - "EngineOilPressure": OncueSensor( - name="EngineOilPressure", - display_name="Engine Oil Pressure", - value=0, - display_value="0 Psi", - unit="Psi", - ), - "EngineCoolantTemperature": OncueSensor( - name="EngineCoolantTemperature", - display_name="Engine Coolant Temperature", - value=32, - display_value="32 F", - unit="F", - ), - "BatteryVoltage": OncueSensor( - name="BatteryVoltage", - display_name="Battery Voltage", - value="13.4", - display_value="13.4 V", - unit="V", - ), - "LubeOilTemperature": OncueSensor( - name="LubeOilTemperature", - display_name="Lube Oil Temperature", - value=32, - display_value="32 F", - unit="F", - ), - "GensetControllerTemperature": OncueSensor( - name="GensetControllerTemperature", - display_name="Generator Controller Temperature", - value=84.2, - display_value="84.2 F", - unit="F", - ), - "EngineCompartmentTemperature": OncueSensor( - name="EngineCompartmentTemperature", - display_name="Engine Compartment Temperature", - value=62.6, - display_value="62.6 F", - unit="F", - ), - "GeneratorTrueTotalPower": OncueSensor( - name="GeneratorTrueTotalPower", - display_name="Generator True Total Power", - value="0.0", - display_value="0.0 W", - unit="W", - ), - "GeneratorTruePercentOfRatedPower": OncueSensor( - name="GeneratorTruePercentOfRatedPower", - display_name="Generator True Percent Of Rated Power", - value="0", - display_value="0 %", - unit="%", - ), - "GeneratorVoltageAB": OncueSensor( - name="GeneratorVoltageAB", - display_name="Generator Voltage AB", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "GeneratorVoltageAverageLineToLine": OncueSensor( - name="GeneratorVoltageAverageLineToLine", - display_name="Generator Voltage Average Line To Line", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "GeneratorCurrentAverage": OncueSensor( - name="GeneratorCurrentAverage", - display_name="Generator Current Average", - value="0.0", - display_value="0.0 A", - unit="A", - ), - "GeneratorFrequency": OncueSensor( - name="GeneratorFrequency", - display_name="Generator Frequency", - value="0.0", - display_value="0.0 Hz", - unit="Hz", - ), - "GensetSerialNumber": OncueSensor( - name="GensetSerialNumber", - display_name="Generator Serial Number", - value="33FDGMFR0026", - display_value="33FDGMFR0026", - unit=None, - ), - "GensetState": OncueSensor( - name="GensetState", - display_name="Generator State", - value="Off", - display_value="Off", - unit=None, - ), - "GensetControllerSerialNumber": OncueSensor( - name="GensetControllerSerialNumber", - display_name="Generator Controller Serial Number", - value="-1", - display_value="-1", - unit=None, - ), - "GensetModelNumberSelect": OncueSensor( - name="GensetModelNumberSelect", - display_name="Genset Model Number Select", - value="38 RCLB", - display_value="38 RCLB", - unit=None, - ), - "GensetControllerClockTime": OncueSensor( - name="GensetControllerClockTime", - display_name="Generator Controller Clock Time", - value="2022-01-13 18:08:13", - display_value="2022-01-13 18:08:13", - unit=None, - ), - "GensetControllerTotalOperationTime": OncueSensor( - name="GensetControllerTotalOperationTime", - display_name="Generator Controller Total Operation Time", - value="16770.8", - display_value="16770.8 h", - unit="h", - ), - "EngineTotalRunTime": OncueSensor( - name="EngineTotalRunTime", - display_name="Engine Total Run Time", - value="28.1", - display_value="28.1 h", - unit="h", - ), - "EngineTotalRunTimeLoaded": OncueSensor( - name="EngineTotalRunTimeLoaded", - display_name="Engine Total Run Time Loaded", - value="5.5", - display_value="5.5 h", - unit="h", - ), - "EngineTotalNumberOfStarts": OncueSensor( - name="EngineTotalNumberOfStarts", - display_name="Engine Total Number Of Starts", - value="101", - display_value="101", - unit=None, - ), - "GensetTotalEnergy": OncueSensor( - name="GensetTotalEnergy", - display_name="Genset Total Energy", - value="1.2022309E7", - display_value="1.2022309E7 kWh", - unit="kWh", - ), - "AtsContactorPosition": OncueSensor( - name="AtsContactorPosition", - display_name="Ats Contactor Position", - value="Source1", - display_value="Source1", - unit=None, - ), - "AtsSourcesAvailable": OncueSensor( - name="AtsSourcesAvailable", - display_name="Ats Sources Available", - value="Source1", - display_value="Source1", - unit=None, - ), - "Source1VoltageAverageLineToLine": OncueSensor( - name="Source1VoltageAverageLineToLine", - display_name="Source1 Voltage Average Line To Line", - value="253.5", - display_value="253.5 V", - unit="V", - ), - "Source2VoltageAverageLineToLine": OncueSensor( - name="Source2VoltageAverageLineToLine", - display_name="Source2 Voltage Average Line To Line", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "IPAddress": OncueSensor( - name="IPAddress", - display_name="IP Address", - value="1.2.3.4:1026", - display_value="1.2.3.4:1026", - unit=None, - ), - "MacAddress": OncueSensor( - name="MacAddress", - display_name="Mac Address", - value="--", - display_value="--", - unit=None, - ), - "ConnectedServerIPAddress": OncueSensor( - name="ConnectedServerIPAddress", - display_name="Connected Server IP Address", - value="40.117.195.28", - display_value="40.117.195.28", - unit=None, - ), - "NetworkConnectionEstablished": OncueSensor( - name="NetworkConnectionEstablished", - display_name="Network Connection Established", - value="true", - display_value="True", - unit=None, - ), - "SerialNumber": OncueSensor( - name="SerialNumber", - display_name="Serial Number", - value="1073879692", - display_value="1073879692", - unit=None, - ), - }, - ) -} - -MOCK_ASYNC_FETCH_ALL_UNAVAILABLE_DEVICE = { - "456789": OncueDevice( - name="My Generator", - state="Off", - product_name="RDC 2.4", - hardware_version="319", - serial_number="SERIAL", - sensors={ - "Product": OncueSensor( - name="Product", - display_name="Controller Type", - value="--", - display_value="RDC 2.4", - unit=None, - ), - "FirmwareVersion": OncueSensor( - name="FirmwareVersion", - display_name="Current Firmware", - value="--", - display_value="2.0.6", - unit=None, - ), - "LatestFirmware": OncueSensor( - name="LatestFirmware", - display_name="Latest Firmware", - value="--", - display_value="2.0.6", - unit=None, - ), - "EngineSpeed": OncueSensor( - name="EngineSpeed", - display_name="Engine Speed", - value="--", - display_value="0 R/min", - unit="R/min", - ), - "EngineTargetSpeed": OncueSensor( - name="EngineTargetSpeed", - display_name="Engine Target Speed", - value="--", - display_value="0 R/min", - unit="R/min", - ), - "EngineOilPressure": OncueSensor( - name="EngineOilPressure", - display_name="Engine Oil Pressure", - value="--", - display_value="0 Psi", - unit="Psi", - ), - "EngineCoolantTemperature": OncueSensor( - name="EngineCoolantTemperature", - display_name="Engine Coolant Temperature", - value="--", - display_value="32 F", - unit="F", - ), - "BatteryVoltage": OncueSensor( - name="BatteryVoltage", - display_name="Battery Voltage", - value="0.0", - display_value="13.4 V", - unit="V", - ), - "LubeOilTemperature": OncueSensor( - name="LubeOilTemperature", - display_name="Lube Oil Temperature", - value="--", - display_value="32 F", - unit="F", - ), - "GensetControllerTemperature": OncueSensor( - name="GensetControllerTemperature", - display_name="Generator Controller Temperature", - value="--", - display_value="84.2 F", - unit="F", - ), - "EngineCompartmentTemperature": OncueSensor( - name="EngineCompartmentTemperature", - display_name="Engine Compartment Temperature", - value="--", - display_value="62.6 F", - unit="F", - ), - "GeneratorTrueTotalPower": OncueSensor( - name="GeneratorTrueTotalPower", - display_name="Generator True Total Power", - value="--", - display_value="0.0 W", - unit="W", - ), - "GeneratorTruePercentOfRatedPower": OncueSensor( - name="GeneratorTruePercentOfRatedPower", - display_name="Generator True Percent Of Rated Power", - value="--", - display_value="0 %", - unit="%", - ), - "GeneratorVoltageAB": OncueSensor( - name="GeneratorVoltageAB", - display_name="Generator Voltage AB", - value="--", - display_value="0.0 V", - unit="V", - ), - "GeneratorVoltageAverageLineToLine": OncueSensor( - name="GeneratorVoltageAverageLineToLine", - display_name="Generator Voltage Average Line To Line", - value="--", - display_value="0.0 V", - unit="V", - ), - "GeneratorCurrentAverage": OncueSensor( - name="GeneratorCurrentAverage", - display_name="Generator Current Average", - value="--", - display_value="0.0 A", - unit="A", - ), - "GeneratorFrequency": OncueSensor( - name="GeneratorFrequency", - display_name="Generator Frequency", - value="--", - display_value="0.0 Hz", - unit="Hz", - ), - "GensetSerialNumber": OncueSensor( - name="GensetSerialNumber", - display_name="Generator Serial Number", - value="--", - display_value="33FDGMFR0026", - unit=None, - ), - "GensetState": OncueSensor( - name="GensetState", - display_name="Generator State", - value="--", - display_value="Off", - unit=None, - ), - "GensetControllerSerialNumber": OncueSensor( - name="GensetControllerSerialNumber", - display_name="Generator Controller Serial Number", - value="--", - display_value="-1", - unit=None, - ), - "GensetModelNumberSelect": OncueSensor( - name="GensetModelNumberSelect", - display_name="Genset Model Number Select", - value="--", - display_value="38 RCLB", - unit=None, - ), - "GensetControllerClockTime": OncueSensor( - name="GensetControllerClockTime", - display_name="Generator Controller Clock Time", - value="--", - display_value="2022-01-13 18:08:13", - unit=None, - ), - "GensetControllerTotalOperationTime": OncueSensor( - name="GensetControllerTotalOperationTime", - display_name="Generator Controller Total Operation Time", - value="--", - display_value="16770.8 h", - unit="h", - ), - "EngineTotalRunTime": OncueSensor( - name="EngineTotalRunTime", - display_name="Engine Total Run Time", - value="--", - display_value="28.1 h", - unit="h", - ), - "EngineTotalRunTimeLoaded": OncueSensor( - name="EngineTotalRunTimeLoaded", - display_name="Engine Total Run Time Loaded", - value="--", - display_value="5.5 h", - unit="h", - ), - "EngineTotalNumberOfStarts": OncueSensor( - name="EngineTotalNumberOfStarts", - display_name="Engine Total Number Of Starts", - value="--", - display_value="101", - unit=None, - ), - "GensetTotalEnergy": OncueSensor( - name="GensetTotalEnergy", - display_name="Genset Total Energy", - value="--", - display_value="1.2022309E7 kWh", - unit="kWh", - ), - "AtsContactorPosition": OncueSensor( - name="AtsContactorPosition", - display_name="Ats Contactor Position", - value="--", - display_value="Source1", - unit=None, - ), - "AtsSourcesAvailable": OncueSensor( - name="AtsSourcesAvailable", - display_name="Ats Sources Available", - value="--", - display_value="Source1", - unit=None, - ), - "Source1VoltageAverageLineToLine": OncueSensor( - name="Source1VoltageAverageLineToLine", - display_name="Source1 Voltage Average Line To Line", - value="--", - display_value="253.5 V", - unit="V", - ), - "Source2VoltageAverageLineToLine": OncueSensor( - name="Source2VoltageAverageLineToLine", - display_name="Source2 Voltage Average Line To Line", - value="--", - display_value="0.0 V", - unit="V", - ), - "IPAddress": OncueSensor( - name="IPAddress", - display_name="IP Address", - value="--", - display_value="1.2.3.4:1026", - unit=None, - ), - "MacAddress": OncueSensor( - name="MacAddress", - display_name="Mac Address", - value="--", - display_value="--", - unit=None, - ), - "ConnectedServerIPAddress": OncueSensor( - name="ConnectedServerIPAddress", - display_name="Connected Server IP Address", - value="--", - display_value="40.117.195.28", - unit=None, - ), - "NetworkConnectionEstablished": OncueSensor( - name="NetworkConnectionEstablished", - display_name="Network Connection Established", - value="--", - display_value="True", - unit=None, - ), - "SerialNumber": OncueSensor( - name="SerialNumber", - display_name="Serial Number", - value="--", - display_value="1073879692", - unit=None, - ), - }, - ) -} - - -def _patch_login_and_data(): - @contextmanager - def _patcher(): - with ( - patch( - "homeassistant.components.oncue.Oncue.async_login", - ), - patch( - "homeassistant.components.oncue.Oncue.async_fetch_all", - return_value=MOCK_ASYNC_FETCH_ALL, - ), - ): - yield - - return _patcher() - - -def _patch_login_and_data_offline_device(): - @contextmanager - def _patcher(): - with ( - patch( - "homeassistant.components.oncue.Oncue.async_login", - ), - patch( - "homeassistant.components.oncue.Oncue.async_fetch_all", - return_value=MOCK_ASYNC_FETCH_ALL_OFFLINE_DEVICE, - ), - ): - yield - - return _patcher() - - -def _patch_login_and_data_unavailable(): - @contextmanager - def _patcher(): - with ( - patch("homeassistant.components.oncue.Oncue.async_login"), - patch( - "homeassistant.components.oncue.Oncue.async_fetch_all", - return_value=MOCK_ASYNC_FETCH_ALL_UNAVAILABLE_DEVICE, - ), - ): - yield - - return _patcher() - - -def _patch_login_and_data_unavailable_device(): - @contextmanager - def _patcher(): - with ( - patch("homeassistant.components.oncue.Oncue.async_login"), - patch( - "homeassistant.components.oncue.Oncue.async_fetch_all", - return_value=MOCK_ASYNC_FETCH_ALL_UNAVAILABLE_DEVICE, - ), - ): - yield - - return _patcher() - - -def _patch_login_and_data_auth_failure(): - @contextmanager - def _patcher(): - with ( - patch( - "homeassistant.components.oncue.Oncue.async_login", - side_effect=LoginFailedException, - ), - patch( - "homeassistant.components.oncue.Oncue.async_fetch_all", - side_effect=LoginFailedException, - ), - ): - yield - - return _patcher() diff --git a/tests/components/oncue/test_binary_sensor.py b/tests/components/oncue/test_binary_sensor.py deleted file mode 100644 index d9fce699d39..00000000000 --- a/tests/components/oncue/test_binary_sensor.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Tests for the oncue binary_sensor.""" - -from __future__ import annotations - -from homeassistant.components import oncue -from homeassistant.components.oncue.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from . import _patch_login_and_data, _patch_login_and_data_unavailable - -from tests.common import MockConfigEntry - - -async def test_binary_sensors(hass: HomeAssistant) -> None: - """Test that the binary sensors are setup with the expected values.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", - ) - config_entry.add_to_hass(hass) - with _patch_login_and_data(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - assert len(hass.states.async_all("binary_sensor")) == 1 - assert ( - hass.states.get( - "binary_sensor.my_generator_network_connection_established" - ).state - == STATE_ON - ) - - -async def test_binary_sensors_not_unavailable(hass: HomeAssistant) -> None: - """Test the network connection established binary sensor is available when connection status is false.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", - ) - config_entry.add_to_hass(hass) - with _patch_login_and_data_unavailable(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - assert len(hass.states.async_all("binary_sensor")) == 1 - assert ( - hass.states.get( - "binary_sensor.my_generator_network_connection_established" - ).state - == STATE_OFF - ) diff --git a/tests/components/oncue/test_config_flow.py b/tests/components/oncue/test_config_flow.py deleted file mode 100644 index 3907242e26c..00000000000 --- a/tests/components/oncue/test_config_flow.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Test the Oncue config flow.""" - -from unittest.mock import patch - -from aiooncue import LoginFailedException - -from homeassistant import config_entries -from homeassistant.components.oncue.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from tests.common import MockConfigEntry - - -async def test_form(hass: HomeAssistant) -> 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.oncue.config_flow.Oncue.async_login"), - patch( - "homeassistant.components.oncue.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "TEST-username", - "password": "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-username" - assert result2["data"] == { - "username": "TEST-username", - "password": "test-password", - } - assert mock_setup_entry.call_count == 1 - - -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": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.oncue.config_flow.Oncue.async_login", - side_effect=LoginFailedException, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {CONF_PASSWORD: "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.oncue.config_flow.Oncue.async_login", - side_effect=TimeoutError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_unknown_exception(hass: HomeAssistant) -> None: - """Test we handle unknown exceptions.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.oncue.config_flow.Oncue.async_login", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_already_configured(hass: HomeAssistant) -> None: - """Test already configured.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - "username": "TEST-username", - "password": "test-password", - }, - unique_id="test-username", - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch("homeassistant.components.oncue.config_flow.Oncue.async_login"): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" - - -async def test_reauth(hass: HomeAssistant) -> None: - """Test reauth flow.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_USERNAME: "any", - CONF_PASSWORD: "old", - }, - ) - config_entry.add_to_hass(hass) - config_entry.async_start_reauth(hass) - await hass.async_block_till_done() - flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) - assert len(flows) == 1 - flow = flows[0] - - with patch( - "homeassistant.components.oncue.config_flow.Oncue.async_login", - side_effect=LoginFailedException, - ): - result2 = await hass.config_entries.flow.async_configure( - flow["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"password": "invalid_auth"} - - with ( - patch("homeassistant.components.oncue.config_flow.Oncue.async_login"), - patch( - "homeassistant.components.oncue.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - flow["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - assert config_entry.data[CONF_PASSWORD] == "test-password" - assert mock_setup_entry.call_count == 1 diff --git a/tests/components/oncue/test_init.py b/tests/components/oncue/test_init.py index cf93b51dee1..204f9eb9ecf 100644 --- a/tests/components/oncue/test_init.py +++ b/tests/components/oncue/test_init.py @@ -1,94 +1,79 @@ -"""Tests for the oncue component.""" +"""Tests for the Oncue integration.""" -from __future__ import annotations - -from datetime import timedelta -from unittest.mock import patch - -from aiooncue import LoginFailedException - -from homeassistant.components import oncue -from homeassistant.components.oncue.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.oncue import DOMAIN +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util +from homeassistant.helpers import issue_registry as ir -from . import _patch_login_and_data, _patch_login_and_data_auth_failure - -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry -async def test_config_entry_reload(hass: HomeAssistant) -> None: - """Test that a config entry can be reloaded.""" - config_entry = MockConfigEntry( +async def test_oncue_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the Oncue configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", ) - config_entry.add_to_hass(hass) - with _patch_login_and_data(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - await hass.config_entries.async_unload(config_entry.entry_id) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.NOT_LOADED + assert config_entry_1.state is ConfigEntryState.LOADED - -async def test_config_entry_login_error(hass: HomeAssistant) -> None: - """Test that a config entry is failed on login error.""" - config_entry = MockConfigEntry( + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", ) - config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.oncue.Oncue.async_login", - side_effect=LoginFailedException, - ): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.SETUP_ERROR + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) -async def test_config_entry_retry_later(hass: HomeAssistant) -> None: - """Test that a config entry retry on connection error.""" - config_entry = MockConfigEntry( + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", ) - config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.oncue.Oncue.async_login", - side_effect=TimeoutError, - ): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.SETUP_RETRY + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + assert config_entry_3.state is ConfigEntryState.NOT_LOADED -async def test_late_auth_failure(hass: HomeAssistant) -> None: - """Test auth fails after already setup.""" - config_entry = MockConfigEntry( + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", ) - config_entry.add_to_hass(hass) - with _patch_login_and_data(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() - with _patch_login_and_data_auth_failure(): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await hass.async_block_till_done() + assert config_entry_4.state is ConfigEntryState.NOT_LOADED - flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) - assert len(flows) == 1 - flow = flows[0] - assert flow["context"]["source"] == "reauth" + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/oncue/test_sensor.py b/tests/components/oncue/test_sensor.py deleted file mode 100644 index e5f55d54062..00000000000 --- a/tests/components/oncue/test_sensor.py +++ /dev/null @@ -1,309 +0,0 @@ -"""Tests for the oncue sensor.""" - -from __future__ import annotations - -import pytest - -from homeassistant.components import oncue -from homeassistant.components.oncue.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE -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 ( - _patch_login_and_data, - _patch_login_and_data_offline_device, - _patch_login_and_data_unavailable, - _patch_login_and_data_unavailable_device, -) - -from tests.common import MockConfigEntry - - -@pytest.mark.parametrize( - ("patcher", "connections"), - [ - (_patch_login_and_data, {("mac", "c9:24:22:6f:14:00")}), - (_patch_login_and_data_offline_device, set()), - ], -) -async def test_sensors( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - patcher, - connections, -) -> None: - """Test that the sensors are setup with the expected values.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", - ) - config_entry.add_to_hass(hass) - with patcher(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - ent = entity_registry.async_get("sensor.my_generator_latest_firmware") - dev = device_registry.async_get(ent.device_id) - assert dev.connections == connections - - assert len(hass.states.async_all("sensor")) == 25 - assert hass.states.get("sensor.my_generator_latest_firmware").state == "2.0.6" - - assert hass.states.get("sensor.my_generator_engine_speed").state == "0" - - assert hass.states.get("sensor.my_generator_engine_oil_pressure").state == "0" - - assert ( - hass.states.get("sensor.my_generator_engine_coolant_temperature").state == "0" - ) - - assert hass.states.get("sensor.my_generator_battery_voltage").state == "13.4" - - assert hass.states.get("sensor.my_generator_lube_oil_temperature").state == "0" - - assert ( - hass.states.get("sensor.my_generator_generator_controller_temperature").state - == "29.0" - ) - - assert ( - hass.states.get("sensor.my_generator_engine_compartment_temperature").state - == "17.0" - ) - - assert ( - hass.states.get("sensor.my_generator_generator_true_total_power").state == "0.0" - ) - - assert ( - hass.states.get( - "sensor.my_generator_generator_true_percent_of_rated_power" - ).state - == "0" - ) - - assert ( - hass.states.get( - "sensor.my_generator_generator_voltage_average_line_to_line" - ).state - == "0.0" - ) - - assert hass.states.get("sensor.my_generator_generator_frequency").state == "0.0" - - assert hass.states.get("sensor.my_generator_generator_state").state == "Off" - - assert ( - hass.states.get( - "sensor.my_generator_generator_controller_total_operation_time" - ).state - == "16770.8" - ) - - assert hass.states.get("sensor.my_generator_engine_total_run_time").state == "28.1" - - assert ( - hass.states.get("sensor.my_generator_ats_contactor_position").state == "Source1" - ) - - assert hass.states.get("sensor.my_generator_ip_address").state == "1.2.3.4:1026" - - assert ( - hass.states.get("sensor.my_generator_connected_server_ip_address").state - == "40.117.195.28" - ) - - assert hass.states.get("sensor.my_generator_engine_target_speed").state == "0" - - assert ( - hass.states.get("sensor.my_generator_engine_total_run_time_loaded").state - == "5.5" - ) - - assert ( - hass.states.get( - "sensor.my_generator_source1_voltage_average_line_to_line" - ).state - == "253.5" - ) - - assert ( - hass.states.get( - "sensor.my_generator_source2_voltage_average_line_to_line" - ).state - == "0.0" - ) - - assert ( - hass.states.get("sensor.my_generator_genset_total_energy").state - == "1.2022309E7" - ) - assert ( - hass.states.get("sensor.my_generator_engine_total_number_of_starts").state - == "101" - ) - assert ( - hass.states.get("sensor.my_generator_generator_current_average").state == "0.0" - ) - - -@pytest.mark.parametrize( - ("patcher", "connections"), - [ - (_patch_login_and_data_unavailable_device, set()), - (_patch_login_and_data_unavailable, {("mac", "c9:24:22:6f:14:00")}), - ], -) -async def test_sensors_unavailable(hass: HomeAssistant, patcher, connections) -> None: - """Test that the sensors are unavailable.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", - ) - config_entry.add_to_hass(hass) - with patcher(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - assert len(hass.states.async_all("sensor")) == 25 - assert ( - hass.states.get("sensor.my_generator_latest_firmware").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_speed").state == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_oil_pressure").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_coolant_temperature").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_battery_voltage").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_lube_oil_temperature").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_generator_controller_temperature").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_compartment_temperature").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_generator_true_total_power").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get( - "sensor.my_generator_generator_true_percent_of_rated_power" - ).state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get( - "sensor.my_generator_generator_voltage_average_line_to_line" - ).state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_generator_frequency").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_generator_state").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get( - "sensor.my_generator_generator_controller_total_operation_time" - ).state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_total_run_time").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_ats_contactor_position").state - == STATE_UNAVAILABLE - ) - - assert hass.states.get("sensor.my_generator_ip_address").state == STATE_UNAVAILABLE - - assert ( - hass.states.get("sensor.my_generator_connected_server_ip_address").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_target_speed").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_total_run_time_loaded").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get( - "sensor.my_generator_source1_voltage_average_line_to_line" - ).state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get( - "sensor.my_generator_source2_voltage_average_line_to_line" - ).state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_genset_total_energy").state - == STATE_UNAVAILABLE - ) - assert ( - hass.states.get("sensor.my_generator_engine_total_number_of_starts").state - == STATE_UNAVAILABLE - ) - assert ( - hass.states.get("sensor.my_generator_generator_current_average").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_battery_voltage").state - == STATE_UNAVAILABLE - ) diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index a81eb03a51c..f3f2fbdad40 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -75,7 +75,6 @@ async def test_agents_info( async def test_agents_list_backups( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_config_entry: MockConfigEntry, ) -> None: """Test agent list backups.""" @@ -105,6 +104,22 @@ async def test_agents_list_backups( ] +async def test_agents_list_backups_with_download_failure( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_onedrive_client: MagicMock, +) -> None: + """Test agent list backups still works if one of the items fails to download.""" + mock_onedrive_client.download_drive_item.side_effect = OneDriveException("test") + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [] + + async def test_agents_get_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -231,6 +246,78 @@ async def test_agents_upload_corrupt_upload( assert "Hash validation failed, backup file might be corrupt" in caplog.text +async def test_agents_upload_metadata_upload_failed( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_onedrive_client: MagicMock, + mock_large_file_upload_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test metadata upload fails.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + mock_onedrive_client.upload_file.side_effect = OneDriveException("test") + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + mock_large_file_upload_client.assert_called_once() + mock_onedrive_client.delete_drive_item.assert_called_once() + assert mock_onedrive_client.update_drive_item.call_count == 0 + + +async def test_agents_upload_metadata_metadata_failed( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_onedrive_client: MagicMock, + mock_large_file_upload_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test metadata upload on file description update.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + mock_onedrive_client.update_drive_item.side_effect = OneDriveException("test") + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + mock_large_file_upload_client.assert_called_once() + assert mock_onedrive_client.update_drive_item.call_count == 1 + assert mock_onedrive_client.delete_drive_item.call_count == 2 + + async def test_agents_download( hass_client: ClientSessionGenerator, mock_onedrive_client: MagicMock, diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index 28186503ead..92a4a34e8fb 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -1,12 +1,10 @@ """Test Onkyo config flow.""" -from typing import Any from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components.onkyo import InputSource from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow from homeassistant.components.onkyo.const import ( DOMAIN, @@ -536,89 +534,6 @@ async def test_reconfigure_new_device(hass: HomeAssistant) -> None: assert config_entry.unique_id == old_unique_id -@pytest.mark.parametrize( - ("user_input", "exception", "error"), - [ - ( - # No host, and thus no host reachable - { - CONF_HOST: None, - "receiver_max_volume": 100, - "max_volume": 100, - "sources": {}, - }, - None, - "cannot_connect", - ), - ( - # No host, and connection exception - { - CONF_HOST: None, - "receiver_max_volume": 100, - "max_volume": 100, - "sources": {}, - }, - Exception(), - "cannot_connect", - ), - ], -) -async def test_import_fail( - hass: HomeAssistant, - user_input: dict[str, Any], - exception: Exception, - error: str, -) -> None: - """Test import flow failed.""" - - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=user_input - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == error - - -async def test_import_success( - hass: HomeAssistant, -) -> None: - """Test import flow succeeded.""" - info = create_receiver_info(1) - - user_input = { - CONF_HOST: info.host, - "receiver_max_volume": 80, - "max_volume": 110, - "sources": { - InputSource("00"): "Auxiliary", - InputSource("01"): "Video", - }, - "info": info, - } - - import_result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=user_input - ) - await hass.async_block_till_done() - - assert import_result["type"] is FlowResultType.CREATE_ENTRY - assert import_result["data"] == {"host": "host 1"} - assert import_result["options"] == { - "volume_resolution": 80, - "max_volume": 100, - "input_sources": { - "00": "Auxiliary", - "01": "Video", - }, - "listening_modes": {}, - } - - @pytest.mark.parametrize( "ignore_missing_translations", [ diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 17a5aad6478..9cf27b4f147 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -111,7 +111,7 @@ async def test_options_unsupported_model( CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_CHAT_MODEL: "o1-mini", - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], }, ) await hass.async_block_till_done() @@ -168,7 +168,6 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ( { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "none", CONF_PROMPT: "bla", }, { @@ -202,6 +201,18 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", CONF_WEB_SEARCH_USER_LOCATION: False, }, + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + ), + ( { CONF_RECOMMENDED: True, CONF_LLM_HASS_API: "assist", @@ -209,7 +220,12 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non }, { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, ), @@ -338,7 +354,7 @@ async def test_options_web_search_unsupported_model( CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_CHAT_MODEL: "o1-pro", - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], CONF_WEB_SEARCH: True, }, ) diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index d6f09e0f30e..269590b483a 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -586,6 +586,11 @@ async def test_function_call( agent_id="conversation.openai", ) + assert mock_create_stream.call_args.kwargs["input"][2] == { + "id": "rs_A", + "summary": [], + "type": "reasoning", + } assert result.response.response_type == intent.IntentResponseType.ACTION_DONE # Don't test the prompt, as it's not deterministic assert mock_chat_log.content[1:] == snapshot diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 5aef68841ee..dc83aa48807 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -136,6 +136,33 @@ async def test_generate_image_service_error( return_response=True, ) + with ( + patch( + "openai.resources.images.AsyncImages.generate", + return_value=ImagesResponse( + created=1700000000, + data=[ + Image( + b64_json=None, + revised_prompt=None, + url=None, + ) + ], + ), + ), + pytest.raises(HomeAssistantError, match="No image returned"), + ): + await hass.services.async_call( + "openai_conversation", + "generate_image", + { + "config_entry": mock_config_entry.entry_id, + "prompt": "Image of an epic fail", + }, + blocking=True, + return_response=True, + ) + @pytest.mark.usefixtures("mock_init_component") async def test_generate_content_service_with_image_not_allowed_path( @@ -262,6 +289,27 @@ async def test_init_error( }, 0, ), + ( + {"prompt": "Picture of a dog", "filenames": ["/a/b/c.pdf"]}, + { + "input": [ + { + "content": [ + { + "type": "input_text", + "text": "Picture of a dog", + }, + { + "type": "input_file", + "file_data": "data:application/pdf;base64,BASE64IMAGE1", + "filename": "/a/b/c.pdf", + }, + ], + }, + ], + }, + 1, + ), ( {"prompt": "Picture of a dog", "filenames": ["/a/b/c.jpg"]}, { @@ -415,8 +463,8 @@ async def test_generate_content_service( [True, False], ), ( - {"prompt": "Not a picture of a dog", "filenames": ["/a/b/c.pdf"]}, - "Only images are supported by the OpenAI API,`/a/b/c.pdf` is not an image file", + {"prompt": "Not a picture of a dog", "filenames": ["/a/b/c.mov"]}, + "Only images and PDF are supported by the OpenAI API,`/a/b/c.mov` is not an image file or PDF", 1, [True], [True], diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index 711cc6c1d86..5c98b4e9260 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -40,6 +40,7 @@ TEST_GATEWAY_ID3 = "SOMFY_PROTECT-v0NT53occUBPyuJRzx59kalW1hFfzimN" TEST_HOST = "gateway-1234-5678-9123.local:8443" TEST_HOST2 = "192.168.11.104:8443" +TEST_TOKEN = "1234123412341234" MOCK_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)] MOCK_GATEWAY2_RESPONSE = [Mock(id=TEST_GATEWAY_ID3), Mock(id=TEST_GATEWAY_ID2)] @@ -152,7 +153,7 @@ async def test_form_only_cloud_supported( async def test_form_local_happy_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test we get the form.""" + """Test local API configuration flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -179,21 +180,27 @@ async def test_form_local_happy_flow( "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): - await hass.config_entries.flow.async_configure( + result4 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, "host": "gateway-1234-5678-1234.local:8443", + "token": TEST_TOKEN, + "verify_ssl": True, }, ) await hass.async_block_till_done() + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["title"] == "gateway-1234-5678-1234.local:8443" + assert result4["data"] == { + "host": "gateway-1234-5678-1234.local:8443", + "token": TEST_TOKEN, + "verify_ssl": True, + "hub": TEST_SERVER, + "api_type": "local", + } assert len(mock_setup_entry.mock_calls) == 1 @@ -262,7 +269,7 @@ async def test_form_invalid_auth_cloud( (MaintenanceException, "server_in_maintenance"), (TooManyAttemptsBannedException, "too_many_attempts"), (UnknownUserException, "unsupported_hardware"), - (NotSuchTokenException, "no_such_token"), + (NotSuchTokenException, "invalid_auth"), (Exception, "unknown"), ], ) @@ -297,8 +304,7 @@ async def test_form_invalid_auth_local( result["flow_id"], { "host": TEST_HOST, - "username": TEST_EMAIL, - "password": TEST_PASSWORD, + "token": TEST_TOKEN, "verify_ssl": True, }, ) @@ -309,52 +315,6 @@ async def test_form_invalid_auth_local( assert result4["errors"] == {"base": error} -async def test_form_local_developer_mode_disabled( - hass: HomeAssistant, mock_setup_entry: AsyncMock -) -> 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 - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"hub": TEST_SERVER}, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "local_or_cloud" - - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"api_type": "local"}, - ) - - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "local" - - with patch.multiple( - "pyoverkiz.client.OverkizClient", - login=AsyncMock(return_value=True), - get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=None), - ): - result4 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "host": "gateway-1234-5678-1234.local:8443", - "verify_ssl": True, - }, - ) - - assert result4["type"] is FlowResultType.FORM - assert result4["errors"] == {"base": "developer_mode_disabled"} - - @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -444,16 +404,18 @@ async def test_cloud_abort_on_duplicate_entry( async def test_local_abort_on_duplicate_entry( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test we get the form.""" + """Test local API configuration is aborted if gateway already exists.""" MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, + version=2, data={ "host": TEST_HOST, - "username": TEST_EMAIL, - "password": TEST_PASSWORD, + "token": TEST_TOKEN, + "verify_ssl": True, "hub": TEST_SERVER, + "api_type": "local", }, ).add_to_hass(hass) @@ -484,15 +446,12 @@ async def test_local_abort_on_duplicate_entry( login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): result4 = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": TEST_HOST, - "username": TEST_EMAIL, - "password": TEST_PASSWORD, + "token": TEST_TOKEN, "verify_ssl": True, }, ) @@ -639,18 +598,18 @@ async def test_cloud_reauth_wrong_account(hass: HomeAssistant) -> None: assert result2["reason"] == "reauth_wrong_account" -async def test_local_reauth_success(hass: HomeAssistant) -> None: - """Test reauthentication flow.""" - +async def test_local_reauth_legacy(hass: HomeAssistant) -> None: + """Test legacy reauthentication flow with username/password.""" mock_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, version=2, data={ + "host": TEST_HOST, "username": TEST_EMAIL, "password": TEST_PASSWORD, + "verify_ssl": True, "hub": TEST_SERVER, - "host": TEST_HOST, "api_type": "local", }, ) @@ -672,36 +631,85 @@ async def test_local_reauth_success(hass: HomeAssistant) -> None: "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD2, + { + "host": TEST_HOST, + "token": "new_token", + "verify_ssl": True, }, ) assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "reauth_successful" - assert mock_entry.data["username"] == TEST_EMAIL - assert mock_entry.data["password"] == TEST_PASSWORD2 + assert mock_entry.data["host"] == TEST_HOST + assert mock_entry.data["token"] == "new_token" + assert mock_entry.data["verify_ssl"] is True + + +async def test_local_reauth_success(hass: HomeAssistant) -> None: + """Test modern local reauth flow.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + version=2, + data={ + "host": TEST_HOST, + "token": "old_token", + "verify_ssl": True, + "hub": TEST_SERVER, + "api_type": "local", + }, + ) + mock_entry.add_to_hass(hass) + + result = await mock_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result2["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": TEST_HOST, + "token": "new_token", + "verify_ssl": True, + }, + ) + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert mock_entry.data["host"] == TEST_HOST + assert mock_entry.data["token"] == "new_token" + assert mock_entry.data["verify_ssl"] is True + assert "username" not in mock_entry.data + assert "password" not in mock_entry.data async def test_local_reauth_wrong_account(hass: HomeAssistant) -> None: - """Test reauthentication flow.""" + """Test local reauth flow with wrong gateway account.""" mock_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID2, version=2, data={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "hub": TEST_SERVER, "host": TEST_HOST, + "token": "old_token", + "verify_ssl": True, + "hub": TEST_SERVER, "api_type": "local", }, ) @@ -722,15 +730,13 @@ async def test_local_reauth_wrong_account(hass: HomeAssistant) -> None: "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD2, + { + "host": TEST_HOST, + "token": "new_token", + "verify_ssl": True, }, ) @@ -897,27 +903,27 @@ async def test_local_zeroconf_flow( "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): result4 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "verify_ssl": False}, + { + "host": "gateway-1234-5678-9123.local:8443", + "token": TEST_TOKEN, + "verify_ssl": False, + }, ) assert result4["type"] is FlowResultType.CREATE_ENTRY assert result4["title"] == "gateway-1234-5678-9123.local:8443" - assert result4["data"] == { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "hub": TEST_SERVER, - "host": "gateway-1234-5678-9123.local:8443", - "api_type": "local", - "token": "1234123412341234", - "verify_ssl": False, - } + # Verify no username/password in data + assert result4["data"] == { + "host": "gateway-1234-5678-9123.local:8443", + "token": TEST_TOKEN, + "verify_ssl": False, + "hub": TEST_SERVER, + "api_type": "local", + } assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/overkiz/test_init.py b/tests/components/overkiz/test_init.py index ba4de56ad86..d1961d79735 100644 --- a/tests/components/overkiz/test_init.py +++ b/tests/components/overkiz/test_init.py @@ -7,7 +7,7 @@ from homeassistant.setup import async_setup_component from .test_config_flow import TEST_EMAIL, TEST_GATEWAY_ID, TEST_PASSWORD, TEST_SERVER -from tests.common import MockConfigEntry, mock_registry +from tests.common import MockConfigEntry, RegistryEntryWithDefaults, mock_registry ENTITY_SENSOR_DISCRETE_RSSI_LEVEL = "sensor.zipscreen_woonkamer_discrete_rssi_level" ENTITY_ALARM_CONTROL_PANEL = "alarm_control_panel.alarm" @@ -33,35 +33,35 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: hass, { # This entity will be migrated to "io://1234-5678-1234/3541212-core:DiscreteRSSILevelState" - ENTITY_SENSOR_DISCRETE_RSSI_LEVEL: er.RegistryEntry( + ENTITY_SENSOR_DISCRETE_RSSI_LEVEL: RegistryEntryWithDefaults( entity_id=ENTITY_SENSOR_DISCRETE_RSSI_LEVEL, unique_id="io://1234-5678-1234/3541212-OverkizState.CORE_DISCRETE_RSSI_LEVEL", platform=DOMAIN, config_entry_id=mock_entry.entry_id, ), # This entity will be migrated to "internal://1234-5678-1234/alarm/0-TSKAlarmController" - ENTITY_ALARM_CONTROL_PANEL: er.RegistryEntry( + ENTITY_ALARM_CONTROL_PANEL: RegistryEntryWithDefaults( entity_id=ENTITY_ALARM_CONTROL_PANEL, unique_id="internal://1234-5678-1234/alarm/0-UIWidget.TSKALARM_CONTROLLER", platform=DOMAIN, config_entry_id=mock_entry.entry_id, ), # This entity will be migrated to "io://1234-5678-1234/0-OnOff" - ENTITY_SWITCH_GARAGE: er.RegistryEntry( + ENTITY_SWITCH_GARAGE: RegistryEntryWithDefaults( entity_id=ENTITY_SWITCH_GARAGE, unique_id="io://1234-5678-1234/0-UIClass.ON_OFF", platform=DOMAIN, config_entry_id=mock_entry.entry_id, ), # This entity will be removed since "io://1234-5678-1234/3541212-core:TargetClosureState" already exists - ENTITY_SENSOR_TARGET_CLOSURE_STATE: er.RegistryEntry( + ENTITY_SENSOR_TARGET_CLOSURE_STATE: RegistryEntryWithDefaults( entity_id=ENTITY_SENSOR_TARGET_CLOSURE_STATE, unique_id="io://1234-5678-1234/3541212-OverkizState.CORE_TARGET_CLOSURE", platform=DOMAIN, config_entry_id=mock_entry.entry_id, ), # This entity will not be migrated" - ENTITY_SENSOR_TARGET_CLOSURE_STATE_2: er.RegistryEntry( + ENTITY_SENSOR_TARGET_CLOSURE_STATE_2: RegistryEntryWithDefaults( entity_id=ENTITY_SENSOR_TARGET_CLOSURE_STATE_2, unique_id="io://1234-5678-1234/3541212-core:TargetClosureState", platform=DOMAIN, diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index 93f40d0ae3d..41565c6b1fd 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -291,13 +291,13 @@ BAD_JSON_SUFFIX = "** and it ends here ^^" @pytest.fixture -def setup_comp( +async def setup_comp( hass: HomeAssistant, mock_device_tracker_conf: list[Device], mqtt_mock: MqttMockHAClient, ) -> None: """Initialize components.""" - hass.loop.run_until_complete(async_setup_component(hass, "device_tracker", {})) + await async_setup_component(hass, "device_tracker", {}) hass.states.async_set("zone.inner", "zoning", INNER_ZONE) @@ -320,7 +320,7 @@ async def setup_owntracks( @pytest.fixture -def context(hass: HomeAssistant, setup_comp: None) -> OwnTracksContextFactory: +async def context(hass: HomeAssistant, setup_comp: None) -> OwnTracksContextFactory: """Set up the mocked context.""" orig_context = owntracks.OwnTracksContext context = None @@ -331,16 +331,14 @@ def context(hass: HomeAssistant, setup_comp: None) -> OwnTracksContextFactory: context = orig_context(*args) return context - hass.loop.run_until_complete( - setup_owntracks( - hass, - { - CONF_MAX_GPS_ACCURACY: 200, - CONF_WAYPOINT_IMPORT: True, - CONF_WAYPOINT_WHITELIST: ["jon", "greg"], - }, - store_context, - ) + await setup_owntracks( + hass, + { + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT: True, + CONF_WAYPOINT_WHITELIST: ["jon", "greg"], + }, + store_context, ) def get_context(): @@ -382,7 +380,7 @@ def assert_location_longitude(hass: HomeAssistant, longitude: float) -> None: assert state.attributes.get("longitude") == longitude -def assert_location_accuracy(hass: HomeAssistant, accuracy: int) -> None: +def assert_location_accuracy(hass: HomeAssistant, accuracy: float) -> None: """Test the assertion of a location accuracy.""" state = hass.states.get(DEVICE_TRACKER_STATE) assert state.attributes.get("gps_accuracy") == accuracy diff --git a/tests/components/owntracks/test_init.py b/tests/components/owntracks/test_init.py index 5ef0efb0ab9..266a66b2760 100644 --- a/tests/components/owntracks/test_init.py +++ b/tests/components/owntracks/test_init.py @@ -43,7 +43,7 @@ def mock_dev_track(mock_device_tracker_conf: list[Device]) -> None: @pytest.fixture -def mock_client( +async def mock_client( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> TestClient: """Start the Home Assistant HTTP component.""" @@ -54,9 +54,9 @@ def mock_client( MockConfigEntry( domain="owntracks", data={"webhook_id": "owntracks_test", "secret": "abcd"} ).add_to_hass(hass) - hass.loop.run_until_complete(async_setup_component(hass, "owntracks", {})) + await async_setup_component(hass, "owntracks", {}) - return hass.loop.run_until_complete(hass_client_no_auth()) + return await hass_client_no_auth() async def test_handle_valid_message(mock_client) -> None: diff --git a/tests/components/person/conftest.py b/tests/components/person/conftest.py index a6dc95ccc9e..2b1724f0c48 100644 --- a/tests/components/person/conftest.py +++ b/tests/components/person/conftest.py @@ -31,7 +31,7 @@ def storage_collection(hass: HomeAssistant) -> person.PersonStorageCollection: @pytest.fixture -def storage_setup( +async def storage_setup( hass: HomeAssistant, hass_storage: dict[str, Any], hass_admin_user: MockUser ) -> None: """Storage setup.""" @@ -49,4 +49,4 @@ def storage_setup( ] }, } - assert hass.loop.run_until_complete(async_setup_component(hass, DOMAIN, {})) + assert await async_setup_component(hass, DOMAIN, {}) diff --git a/tests/components/pglab/test_common.py b/tests/components/pglab/test_common.py new file mode 100644 index 00000000000..0ff3271d5d6 --- /dev/null +++ b/tests/components/pglab/test_common.py @@ -0,0 +1,50 @@ +"""Common code for PG LAB Electronics tests.""" + +import json + +from homeassistant.core import HomeAssistant + +from tests.common import async_fire_mqtt_message + + +def get_device_discovery_payload( + number_of_shutters: int, + number_of_boards: int, + device_name: str = "test", +) -> dict[str, any]: + """Return the device discovery payload.""" + + # be sure the number of shutters and boards are in the correct range + assert 0 <= number_of_boards <= 8 + assert 0 <= number_of_shutters <= (number_of_boards * 4) + + # define the number of E-RELAY boards connected to E-BOARD + boards = "1" * number_of_boards + "0" * (8 - number_of_boards) + + return { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": device_name, + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-BOARD", + "id": "E-BOARD-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": number_of_shutters, "boards": boards}, + } + + +async def send_discovery_message( + hass: HomeAssistant, + payload: dict[str, any] | None, +) -> None: + """Send the discovery message to make E-BOARD device discoverable.""" + + topic = "pglab/discovery/E-BOARD-DD53AC85/config" + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload if payload is not None else ""), + ) + await hass.async_block_till_done() diff --git a/tests/components/pglab/test_cover.py b/tests/components/pglab/test_cover.py index ea4c7a7213e..aa92e2da433 100644 --- a/tests/components/pglab/test_cover.py +++ b/tests/components/pglab/test_cover.py @@ -1,7 +1,5 @@ """The tests for the PG LAB Electronics cover.""" -import json - from homeassistant.components import cover from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, @@ -19,6 +17,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant +from .test_common import get_device_discovery_payload, send_discovery_message + from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient @@ -43,25 +43,13 @@ async def test_cover_features( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Test cover features.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 4, "boards": "10000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=4, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) assert len(hass.states.async_all("cover")) == 4 @@ -75,25 +63,13 @@ async def test_cover_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Check if covers are properly created.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 6, "boards": "11000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=6, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # We are creating 6 covers using two E-RELAY devices connected to E-BOARD. # Now we are going to check if all covers are created and their state is unknown. @@ -111,25 +87,12 @@ async def test_cover_change_state_via_mqtt( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Test state update via MQTT.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 2, "boards": "10000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=2, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Check initial state is unknown cover = hass.states.get("cover.test_shutter_0") @@ -165,25 +128,13 @@ async def test_cover_mqtt_state_by_calling_service( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Calling service to OPEN/CLOSE cover and check mqtt state.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 2, "boards": "10000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=2, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) cover = hass.states.get("cover.test_shutter_0") assert cover.state == STATE_UNKNOWN diff --git a/tests/components/pglab/test_discovery.py b/tests/components/pglab/test_discovery.py index 65716236277..df897264163 100644 --- a/tests/components/pglab/test_discovery.py +++ b/tests/components/pglab/test_discovery.py @@ -1,13 +1,12 @@ """The tests for the PG LAB Electronics discovery device.""" -import json - from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from tests.common import async_fire_mqtt_message +from .test_common import get_device_discovery_payload, send_discovery_message + from tests.typing import MqttMockHAClient @@ -19,25 +18,13 @@ async def test_device_discover( setup_pglab, ) -> None: """Test setting up a device.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Verify device and registry entries are created device_entry = device_reg.async_get_device( @@ -60,25 +47,12 @@ async def test_device_update( snapshot: SnapshotAssertion, ) -> None: """Test update a device.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Verify device is created device_entry = device_reg.async_get_device( @@ -90,12 +64,7 @@ async def test_device_update( payload["fw"] = "1.0.1" payload["hw"] = "1.0.8" - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), - ) - await hass.async_block_till_done() + await send_discovery_message(hass, payload) # Verify device is created device_entry = device_reg.async_get_device( @@ -114,25 +83,12 @@ async def test_device_remove( setup_pglab, ) -> None: """Test remove a device.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Verify device is created device_entry = device_reg.async_get_device( @@ -140,12 +96,7 @@ async def test_device_remove( ) assert device_entry is not None - async_fire_mqtt_message( - hass, - topic, - "", - ) - await hass.async_block_till_done() + await send_discovery_message(hass, None) # Verify device entry is removed device_entry = device_reg.async_get_device( diff --git a/tests/components/pglab/test_sensor.py b/tests/components/pglab/test_sensor.py index ff20d1452a4..75932dd036c 100644 --- a/tests/components/pglab/test_sensor.py +++ b/tests/components/pglab/test_sensor.py @@ -8,34 +8,12 @@ from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant +from .test_common import get_device_discovery_payload, send_discovery_message + from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient -async def send_discovery_message(hass: HomeAssistant) -> None: - """Send mqtt discovery message.""" - - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "00000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), - ) - await hass.async_block_till_done() - - @freeze_time("2024-02-26 01:21:34") @pytest.mark.parametrize( "sensor_suffix", @@ -55,7 +33,12 @@ async def test_sensors( """Check if sensors are properly created and updated.""" # send the discovery message to make E-BOARD device discoverable - await send_discovery_message(hass) + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=0, + ) + + await send_discovery_message(hass, payload) # check initial sensors state state = hass.states.get(f"sensor.test_{sensor_suffix}") diff --git a/tests/components/pglab/test_switch.py b/tests/components/pglab/test_switch.py index fef445f80f3..0f1a2e4bb04 100644 --- a/tests/components/pglab/test_switch.py +++ b/tests/components/pglab/test_switch.py @@ -1,7 +1,6 @@ """The tests for the PG LAB Electronics switch.""" from datetime import timedelta -import json from homeassistant import config_entries from homeassistant.components.switch import ( @@ -20,6 +19,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util +from .test_common import get_device_discovery_payload, send_discovery_message + from tests.common import async_fire_mqtt_message, async_fire_time_changed from tests.typing import MqttMockHAClient @@ -38,25 +39,13 @@ async def test_available_relay( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Check if relay are properly created when two E-Relay boards are connected.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) for i in range(16): state = hass.states.get(f"switch.test_relay_{i}") @@ -68,25 +57,13 @@ async def test_change_state_via_mqtt( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Test state update via MQTT.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "10000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Simulate response from the device state = hass.states.get("switch.test_relay_0") @@ -123,25 +100,13 @@ async def test_mqtt_state_by_calling_service( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Calling service to turn ON/OFF relay and check mqtt state.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "10000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Turn relay ON await call_service(hass, "switch.test_relay_0", SERVICE_TURN_ON) @@ -177,26 +142,13 @@ async def test_discovery_update( ) -> None: """Update discovery message and check if relay are property updated.""" - # publish the first discovery message - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "first_test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "10000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + device_name="first_test", + number_of_shutters=0, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # test the available relay in the first configuration for i in range(8): @@ -206,25 +158,13 @@ async def test_discovery_update( # prepare a new message ... the same device but renamed # and with different relay configuration - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "second_test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + device_name="second_test", + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # be sure that old relay are been removed for i in range(8): @@ -245,25 +185,12 @@ async def test_disable_entity_state_change_via_mqtt( ) -> None: """Test state update via MQTT of disable entity.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "10000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Be sure that the entity relay_0 is available state = hass.states.get("switch.test_relay_0") @@ -298,12 +225,7 @@ async def test_disable_entity_state_change_via_mqtt( await hass.async_block_till_done() # Re-send the discovery message - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), - ) - await hass.async_block_till_done() + await send_discovery_message(hass, payload) # Be sure that the state is not changed state = hass.states.get("switch.test_relay_0") diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 42dcf449168..2644f0f21c6 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -856,7 +856,7 @@ async def test_client_header_issues(hass: HomeAssistant) -> None: patch("plexauth.PlexAuth.initiate_auth"), patch("plexauth.PlexAuth.token", return_value=None), patch( - "homeassistant.components.http.current_request.get", + "homeassistant.helpers.http.current_request.get", return_value=MockRequest(), ), pytest.raises( diff --git a/tests/components/plex/test_update.py b/tests/components/plex/test_update.py index 7ad2481a726..dbdee5f9390 100644 --- a/tests/components/plex/test_update.py +++ b/tests/components/plex/test_update.py @@ -16,7 +16,7 @@ from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator -UPDATE_ENTITY = "update.plex_media_server_plex_server_1" +UPDATE_ENTITY = "update.plex_server_1_update" async def test_plex_update( diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py index ea003af86c7..4de1c9a4583 100644 --- a/tests/components/point/test_config_flow.py +++ b/tests/components/point/test_config_flow.py @@ -10,7 +10,6 @@ from homeassistant.components.application_credentials import ( async_import_client_credential, ) from homeassistant.components.point.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow @@ -157,16 +156,3 @@ async def test_reauthentication_flow( assert result["type"] is FlowResultType.ABORT assert result["reason"] == expected assert old_entry.unique_id == expected_unique_id - - -async def test_import_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test import flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "pick_implementation" diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index 9b533304fbc..7f23550f522 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -118,7 +118,6 @@ async def test_sensors(hass: HomeAssistant, device_registry: dr.DeviceRegistry) expected_attributes = { "unit_of_measurement": PERCENTAGE, "friendly_name": "MySite Backup reserve", - "device_class": "battery", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears diff --git a/tests/components/pterodactyl/test_config_flow.py b/tests/components/pterodactyl/test_config_flow.py index 14bb2d2f69f..88247085083 100644 --- a/tests/components/pterodactyl/test_config_flow.py +++ b/tests/components/pterodactyl/test_config_flow.py @@ -1,19 +1,30 @@ """Test the Pterodactyl config flow.""" from pydactyl import PterodactylClient -from pydactyl.exceptions import ClientConfigError, PterodactylApiError +from pydactyl.exceptions import BadRequestError, PterodactylApiError import pytest +from requests.exceptions import HTTPError +from requests.models import Response from homeassistant.components.pterodactyl.const import DOMAIN from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import TEST_URL, TEST_USER_INPUT +from .conftest import TEST_API_KEY, TEST_URL, TEST_USER_INPUT from tests.common import MockConfigEntry +def mock_response(): + """Mock HTTP response.""" + mock = Response() + mock.status_code = 401 + + return mock + + @pytest.mark.usefixtures("mock_pterodactyl", "mock_setup_entry") async def test_full_flow(hass: HomeAssistant) -> None: """Test full flow without errors.""" @@ -36,18 +47,21 @@ async def test_full_flow(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize( - "exception_type", + ("exception_type", "expected_error"), [ - ClientConfigError, - PterodactylApiError, + (PterodactylApiError, "cannot_connect"), + (BadRequestError, "cannot_connect"), + (Exception, "unknown"), + (HTTPError(response=mock_response()), "invalid_auth"), ], ) -async def test_recovery_after_api_error( +async def test_recovery_after_error( hass: HomeAssistant, - exception_type, + exception_type: Exception, + expected_error: str, mock_pterodactyl: PterodactylClient, ) -> None: - """Test recovery after an API error.""" + """Test recovery after an error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -63,7 +77,7 @@ async def test_recovery_after_api_error( await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": expected_error} mock_pterodactyl.reset_mock(side_effect=True) @@ -77,46 +91,10 @@ async def test_recovery_after_api_error( assert result["data"] == TEST_USER_INPUT -@pytest.mark.usefixtures("mock_setup_entry") -async def test_recovery_after_unknown_error( - hass: HomeAssistant, - mock_pterodactyl: PterodactylClient, -) -> None: - """Test recovery after an API error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - mock_pterodactyl.client.servers.list_servers.side_effect = Exception - - result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], - user_input=TEST_USER_INPUT, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "unknown"} - - mock_pterodactyl.reset_mock(side_effect=True) - - result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input=TEST_USER_INPUT - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == TEST_URL - assert result["data"] == TEST_USER_INPUT - - -@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.usefixtures("mock_setup_entry", "mock_pterodactyl") async def test_service_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_pterodactyl: PterodactylClient, ) -> None: """Test config flow abort if the Pterodactyl server is already configured.""" mock_config_entry.add_to_hass(hass) @@ -127,3 +105,68 @@ async def test_service_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_pterodactyl", "mock_setup_entry") +async def test_reauth_full_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth config flow 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" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_URL] == TEST_URL + assert mock_config_entry.data[CONF_API_KEY] == TEST_API_KEY + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception_type", "expected_error"), + [ + (PterodactylApiError, "cannot_connect"), + (BadRequestError, "cannot_connect"), + (Exception, "unknown"), + (HTTPError(response=mock_response()), "invalid_auth"), + ], +) +async def test_reauth_recovery_after_error( + hass: HomeAssistant, + exception_type: Exception, + expected_error: str, + mock_config_entry: MockConfigEntry, + mock_pterodactyl: PterodactylClient, +) -> None: + """Test recovery after an error during re-authentication.""" + 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_pterodactyl.client.servers.list_servers.side_effect = exception_type + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_pterodactyl.reset_mock(side_effect=True) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_URL] == TEST_URL + assert mock_config_entry.data[CONF_API_KEY] == TEST_API_KEY diff --git a/tests/components/rainforest_eagle/test_sensor.py b/tests/components/rainforest_eagle/test_sensor.py index 31630913a70..b7e811b69ef 100644 --- a/tests/components/rainforest_eagle/test_sensor.py +++ b/tests/components/rainforest_eagle/test_sensor.py @@ -10,17 +10,17 @@ async def test_sensors_200(hass: HomeAssistant, setup_rainforest_200) -> None: """Test the sensors.""" assert len(hass.states.async_all()) == 3 - demand = hass.states.get("sensor.eagle_200_meter_power_demand") + demand = hass.states.get("sensor.eagle_200_power_demand") assert demand is not None assert demand.state == "1.152000" assert demand.attributes["unit_of_measurement"] == "kW" - delivered = hass.states.get("sensor.eagle_200_total_meter_energy_delivered") + delivered = hass.states.get("sensor.eagle_200_total_energy_delivered") assert delivered is not None assert delivered.state == "45251.285000" assert delivered.attributes["unit_of_measurement"] == "kWh" - received = hass.states.get("sensor.eagle_200_total_meter_energy_received") + received = hass.states.get("sensor.eagle_200_total_energy_received") assert received is not None assert received.state == "232.232000" assert received.attributes["unit_of_measurement"] == "kWh" @@ -33,7 +33,7 @@ async def test_sensors_200(hass: HomeAssistant, setup_rainforest_200) -> None: assert len(hass.states.async_all()) == 4 - price = hass.states.get("sensor.eagle_200_meter_price") + price = hass.states.get("sensor.eagle_200_energy_price") assert price is not None assert price.state == "0.053990" assert price.attributes["unit_of_measurement"] == "USD/kWh" @@ -43,17 +43,17 @@ async def test_sensors_100(hass: HomeAssistant, setup_rainforest_100) -> None: """Test the sensors.""" assert len(hass.states.async_all()) == 3 - demand = hass.states.get("sensor.eagle_100_meter_power_demand") + demand = hass.states.get("sensor.eagle_100_power_demand") assert demand is not None assert demand.state == "1.152000" assert demand.attributes["unit_of_measurement"] == "kW" - delivered = hass.states.get("sensor.eagle_100_total_meter_energy_delivered") + delivered = hass.states.get("sensor.eagle_100_total_energy_delivered") assert delivered is not None assert delivered.state == "45251.285000" assert delivered.attributes["unit_of_measurement"] == "kWh" - received = hass.states.get("sensor.eagle_100_total_meter_energy_received") + received = hass.states.get("sensor.eagle_100_total_energy_received") assert received is not None assert received.state == "232.232000" assert received.attributes["unit_of_measurement"] == "kWh" diff --git a/tests/components/rainforest_raven/snapshots/test_sensor.ambr b/tests/components/rainforest_raven/snapshots/test_sensor.ambr index 618766c1613..fc0d5862352 100644 --- a/tests/components/rainforest_raven/snapshots/test_sensor.ambr +++ b/tests/components/rainforest_raven/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensors[sensor.raven_device_meter_power_demand-entry] +# name: test_sensors[sensor.raven_device_energy_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -14,59 +14,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.raven_device_meter_power_demand', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Meter power demand', - 'platform': 'rainforest_raven', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'power_demand', - 'unique_id': '1234567890abcdef.InstantaneousDemand.demand', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.raven_device_meter_power_demand-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'RAVEn Device Meter power demand', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.raven_device_meter_power_demand', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1.2345', - }) -# --- -# name: test_sensors[sensor.raven_device_meter_price-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.raven_device_meter_price', + 'entity_id': 'sensor.raven_device_energy_price', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -78,33 +26,85 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Meter price', + 'original_name': 'Energy price', 'platform': 'rainforest_raven', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'meter_price', + 'translation_key': 'energy_price', 'unique_id': '1234567890abcdef.PriceCluster.price', 'unit_of_measurement': 'USD/kWh', }) # --- -# name: test_sensors[sensor.raven_device_meter_price-state] +# name: test_sensors[sensor.raven_device_energy_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'RAVEn Device Meter price', + 'friendly_name': 'RAVEn Device Energy price', 'rate_label': 'Set by user', 'state_class': , 'tier': 3, 'unit_of_measurement': 'USD/kWh', }), 'context': , - 'entity_id': 'sensor.raven_device_meter_price', + 'entity_id': 'sensor.raven_device_energy_price', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.10', }) # --- -# name: test_sensors[sensor.raven_device_meter_signal_strength-entry] +# name: test_sensors[sensor.raven_device_power_demand-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.raven_device_power_demand', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power demand', + 'platform': 'rainforest_raven', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_demand', + 'unique_id': '1234567890abcdef.InstantaneousDemand.demand', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.raven_device_power_demand-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'RAVEn Device Power demand', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.raven_device_power_demand', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.2345', + }) +# --- +# name: test_sensors[sensor.raven_device_signal_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -119,7 +119,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.raven_device_meter_signal_strength', + 'entity_id': 'sensor.raven_device_signal_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -131,7 +131,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Meter signal strength', + 'original_name': 'Signal strength', 'platform': 'rainforest_raven', 'previous_unique_id': None, 'supported_features': 0, @@ -140,23 +140,23 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensors[sensor.raven_device_meter_signal_strength-state] +# name: test_sensors[sensor.raven_device_signal_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'channel': 13, - 'friendly_name': 'RAVEn Device Meter signal strength', + 'friendly_name': 'RAVEn Device Signal strength', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.raven_device_meter_signal_strength', + 'entity_id': 'sensor.raven_device_signal_strength', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '100', }) # --- -# name: test_sensors[sensor.raven_device_total_meter_energy_delivered-entry] +# name: test_sensors[sensor.raven_device_total_energy_delivered-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -171,7 +171,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.raven_device_total_meter_energy_delivered', + 'entity_id': 'sensor.raven_device_total_energy_delivered', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -183,7 +183,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total meter energy delivered', + 'original_name': 'Total energy delivered', 'platform': 'rainforest_raven', 'previous_unique_id': None, 'supported_features': 0, @@ -192,23 +192,23 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.raven_device_total_meter_energy_delivered-state] +# name: test_sensors[sensor.raven_device_total_energy_delivered-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'RAVEn Device Total meter energy delivered', + 'friendly_name': 'RAVEn Device Total energy delivered', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.raven_device_total_meter_energy_delivered', + 'entity_id': 'sensor.raven_device_total_energy_delivered', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '23456.7890', }) # --- -# name: test_sensors[sensor.raven_device_total_meter_energy_received-entry] +# name: test_sensors[sensor.raven_device_total_energy_received-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -223,7 +223,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.raven_device_total_meter_energy_received', + 'entity_id': 'sensor.raven_device_total_energy_received', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -235,7 +235,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total meter energy received', + 'original_name': 'Total energy received', 'platform': 'rainforest_raven', 'previous_unique_id': None, 'supported_features': 0, @@ -244,16 +244,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.raven_device_total_meter_energy_received-state] +# name: test_sensors[sensor.raven_device_total_energy_received-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'RAVEn Device Total meter energy received', + 'friendly_name': 'RAVEn Device Total energy received', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.raven_device_total_meter_energy_received', + 'entity_id': 'sensor.raven_device_total_energy_received', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/recorder/table_managers/test_statistics_meta.py b/tests/components/recorder/table_managers/test_statistics_meta.py index 66edb84c3ef..1af60b71ed5 100644 --- a/tests/components/recorder/table_managers/test_statistics_meta.py +++ b/tests/components/recorder/table_managers/test_statistics_meta.py @@ -2,10 +2,19 @@ from __future__ import annotations +import logging +import threading + import pytest from homeassistant.components import recorder +from homeassistant.components.recorder.db_schema import StatisticsMeta +from homeassistant.components.recorder.models import ( + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.util import session_scope +from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant from tests.typing import RecorderInstanceGenerator @@ -55,3 +64,78 @@ async def test_unsafe_calls_to_statistics_meta_manager( session, statistic_ids=["light.kitchen"], ) + + +async def test_invalid_mean_types( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test passing invalid mean types will be skipped and logged.""" + instance = await async_setup_recorder_instance( + hass, {recorder.CONF_COMMIT_INTERVAL: 0} + ) + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + valid_metadata: dict[str, tuple[int, StatisticMetaData]] = { + "sensor.energy": ( + 1, + { + "mean_type": StatisticMeanType.NONE, + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.energy", + "unit_of_measurement": "kWh", + }, + ), + "sensor.wind_direction": ( + 2, + { + "mean_type": StatisticMeanType.CIRCULAR, + "has_mean": False, + "has_sum": False, + "name": "Wind direction", + "source": "recorder", + "statistic_id": "sensor.wind_direction", + "unit_of_measurement": DEGREE, + }, + ), + "sensor.wind_speed": ( + 3, + { + "mean_type": StatisticMeanType.ARITHMETIC, + "has_mean": True, + "has_sum": False, + "name": "Wind speed", + "source": "recorder", + "statistic_id": "sensor.wind_speed", + "unit_of_measurement": "km/h", + }, + ), + } + manager = instance.statistics_meta_manager + with instance.get_session() as session: + for _, metadata in valid_metadata.values(): + session.add(StatisticsMeta.from_meta(metadata)) + + # Add invalid mean type + session.add( + StatisticsMeta( + statistic_id="sensor.invalid", + source="recorder", + has_sum=False, + name="Invalid", + mean_type=12345, + ) + ) + session.commit() + + # Check that the invalid mean type was skipped + assert manager.get_many(session) == valid_metadata + assert ( + "homeassistant.components.recorder.table_managers.statistics_meta", + logging.WARNING, + "Invalid mean type found for statistic_id: sensor.invalid, mean_type: 12345. Skipping", + ) in caplog.record_tuples diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 95cd959db3b..2023e15176f 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -77,6 +77,7 @@ from homeassistant.helpers import ( issue_registry as ir, recorder as recorder_helper, ) +from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -2798,3 +2799,22 @@ async def test_empty_entity_id( hass.bus.async_fire("hello", {"entity_id": ""}) await async_wait_recording_done(hass) assert "Invalid entity ID" not in caplog.text + + +async def test_setting_up_recorder_fails_entity_registry_listener( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test recorder setup fails if an entity registry listener is in place.""" + async_track_entity_registry_updated_event(hass, "test.test", lambda x: x) + recorder_helper.async_initialize_recorder(hass) + with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True): + assert not await async_setup_component( + hass, + recorder.DOMAIN, + {recorder.DOMAIN: {recorder.CONF_DB_URL: "sqlite://"}}, + ) + await hass.async_block_till_done() + assert ( + "The recorder entity registry listener must be installed before " + "async_track_entity_registry_updated_event is called" in caplog.text + ) diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index a4e4fe45db1..2460de994ec 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -698,17 +698,33 @@ def _circular_mean(values: Iterable[StatisticData]) -> dict[str, float]: } -def _circular_mean_approx(values: Iterable[StatisticData]) -> ApproxBase: - return pytest.approx(_circular_mean(values)["mean"]) +def _circular_mean_approx( + values: Iterable[StatisticData], tolerance: float | None = None +) -> ApproxBase: + return pytest.approx(_circular_mean(values)["mean"], abs=tolerance) @pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) @pytest.mark.usefixtures("recorder_mock") @pytest.mark.parametrize("offset", [0, 1, 2]) +@pytest.mark.parametrize( + ("step_size", "tolerance"), + [ + (123.456, 1e-4), + # In this case the angles are uniformly distributed and the mean is undefined. + # This edge case is not handled by the current implementation, but the test + # checks the behavior is consistent. + # We could consider returning None in this case, or returning also an estimate + # of the variance. + (120, 10), + ], +) async def test_statistic_during_period_circular_mean( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, offset: int, + step_size: float, + tolerance: float, ) -> None: """Test statistic_during_period.""" now = dt_util.utcnow() @@ -724,7 +740,7 @@ async def test_statistic_during_period_circular_mean( imported_stats_5min: list[StatisticData] = [ { "start": (start + timedelta(minutes=5 * i)), - "mean": (123.456 * i) % 360, + "mean": (step_size * i) % 360, "mean_weight": 1, } for i in range(39) @@ -807,7 +823,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min), + "mean": _circular_mean_approx(imported_stats_5min, tolerance), "max": None, "min": None, "change": None, @@ -835,7 +851,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min), + "mean": _circular_mean_approx(imported_stats_5min, tolerance), "max": None, "min": None, "change": None, @@ -863,7 +879,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min), + "mean": _circular_mean_approx(imported_stats_5min, tolerance), "max": None, "min": None, "change": None, @@ -887,7 +903,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min[26:]), + "mean": _circular_mean_approx(imported_stats_5min[26:], tolerance), "max": None, "min": None, "change": None, @@ -910,7 +926,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min[26:]), + "mean": _circular_mean_approx(imported_stats_5min[26:], tolerance), "max": None, "min": None, "change": None, @@ -934,7 +950,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min[:26]), + "mean": _circular_mean_approx(imported_stats_5min[:26], tolerance), "max": None, "min": None, "change": None, @@ -964,7 +980,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min[26:32]), + "mean": _circular_mean_approx(imported_stats_5min[26:32], tolerance), "max": None, "min": None, "change": None, @@ -986,7 +1002,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min[24 - offset :]), + "mean": _circular_mean_approx(imported_stats_5min[24 - offset :], tolerance), "max": None, "min": None, "change": None, @@ -1005,7 +1021,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min[24 - offset :]), + "mean": _circular_mean_approx(imported_stats_5min[24 - offset :], tolerance), "max": None, "min": None, "change": None, @@ -1027,7 +1043,9 @@ async def test_statistic_during_period_circular_mean( slice_start = 24 - offset slice_end = 36 - offset assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min[slice_start:slice_end]), + "mean": _circular_mean_approx( + imported_stats_5min[slice_start:slice_end], tolerance + ), "max": None, "min": None, "change": None, @@ -1044,7 +1062,7 @@ async def test_statistic_during_period_circular_mean( response = await client.receive_json() assert response["success"] assert response["result"] == { - "mean": _circular_mean_approx(imported_stats_5min), + "mean": _circular_mean_approx(imported_stats_5min, tolerance), } diff --git a/tests/components/rehlko/__init__.py b/tests/components/rehlko/__init__.py new file mode 100644 index 00000000000..437138a713d --- /dev/null +++ b/tests/components/rehlko/__init__.py @@ -0,0 +1 @@ +"""Rehlko Tests Package.""" diff --git a/tests/components/rehlko/conftest.py b/tests/components/rehlko/conftest.py new file mode 100644 index 00000000000..f5e5a00142b --- /dev/null +++ b/tests/components/rehlko/conftest.py @@ -0,0 +1,100 @@ +"""Module for testing the Rehlko integration in Home Assistant.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.rehlko import CONF_REFRESH_TOKEN, DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_value_fixture + +TEST_EMAIL = "MyEmail@email.com" +TEST_PASSWORD = "password" +TEST_SUBJECT = TEST_EMAIL.lower() +TEST_REFRESH_TOKEN = "my_refresh_token" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.rehlko.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="homes") +def rehlko_homes_fixture() -> list[dict[str, Any]]: + """Create sonos favorites fixture.""" + return load_json_value_fixture("homes.json", DOMAIN) + + +@pytest.fixture(name="generator") +def rehlko_generator_fixture() -> dict[str, Any]: + """Create sonos favorites fixture.""" + return load_json_value_fixture("generator.json", DOMAIN) + + +@pytest.fixture(name="rehlko_config_entry") +def rehlko_config_entry_fixture() -> MockConfigEntry: + """Create a config entry fixture.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + unique_id=TEST_SUBJECT, + ) + + +@pytest.fixture(name="rehlko_config_entry_with_refresh_token") +def rehlko_config_entry_with_refresh_token_fixture() -> MockConfigEntry: + """Create a config entry fixture with refresh token.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + CONF_REFRESH_TOKEN: TEST_REFRESH_TOKEN, + }, + unique_id=TEST_SUBJECT, + ) + + +@pytest.fixture +async def mock_rehlko( + homes: list[dict[str, Any]], + generator: dict[str, Any], +): + """Mock Rehlko instance.""" + with ( + patch("homeassistant.components.rehlko.AioKem", autospec=True) as mock_kem, + patch("homeassistant.components.rehlko.config_flow.AioKem", new=mock_kem), + ): + client = mock_kem.return_value + client.get_homes = AsyncMock(return_value=homes) + client.get_generator_data = AsyncMock(return_value=generator) + client.authenticate = AsyncMock(return_value=None) + client.get_token_subject = Mock(return_value=TEST_SUBJECT) + client.get_refresh_token = AsyncMock(return_value=TEST_REFRESH_TOKEN) + client.set_refresh_token_callback = Mock() + client.set_retry_policy = Mock() + yield client + + +@pytest.fixture +async def load_rehlko_config_entry( + hass: HomeAssistant, + mock_rehlko: Mock, + rehlko_config_entry: MockConfigEntry, +) -> None: + """Load the config entry.""" + rehlko_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(rehlko_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/rehlko/fixtures/generator.json b/tests/components/rehlko/fixtures/generator.json new file mode 100644 index 00000000000..fa1d4d0b45b --- /dev/null +++ b/tests/components/rehlko/fixtures/generator.json @@ -0,0 +1,191 @@ +{ + "device": { + "id": 12345, + "serialNumber": "123MGVHR4567", + "displayName": "Generator 1", + "deviceHost": "Oncue", + "hasAcceptedPrivacyPolicy": true, + "address": { + "lat": 41.3341111, + "long": -72.3333111, + "address1": "Highway 66", + "address2": null, + "city": "Somewhere", + "state": "CA", + "postalCode": "00000", + "country": "US" + }, + "product": "Rdc2v4", + "productDisplayName": "RDC 2.4", + "controllerType": "RDC2 (Blue Board)", + "firmwareVersion": "3.4.5", + "currentFirmware": "RDC2.4 3.4.5", + "isConnected": true, + "lastConnectedTimestamp": "2025-04-14T09:30:17+00:00", + "deviceIpAddress": "1.1.1.1:2402", + "macAddress": "91:E1:20:63:10:00", + "status": "ReadyToRun", + "statusUpdateTimestamp": "2025-04-14T09:29:01+00:00", + "dealerOrgs": [ + { + "id": 123, + "businessPartnerNo": "123456", + "name": "Generators R Us", + "e164PhoneNumber": "+199999999999", + "displayPhoneNumber": "(999) 999-9999", + "wizardStep": "OnboardingComplete", + "wizardComplete": true, + "address": { + "lat": null, + "long": null, + "address1": "Highway 66", + "address2": null, + "city": "Revisited", + "state": "CA", + "postalCode": "000000", + "country": null + }, + "userCount": 4, + "technicianCount": 3, + "deviceCount": 71, + "adminEmails": ["admin@gmail.com"] + } + ], + "alertCount": 0, + "model": "Model20KW", + "modelDisplayName": "20 KW", + "lastMaintenanceTimestamp": "2025-04-10T09:12:59", + "nextMaintenanceTimestamp": "2026-04-10T09:12:59", + "maintenancePeriodDays": 365, + "hasServiceAgreement": null, + "totalRuntimeHours": 120.2 + }, + "powerSource": "Utility", + "switchState": "Auto", + "coolingType": "Air", + "connectionType": "Unknown", + "serverIpAddress": "2.2.2.2", + "serviceAgreement": { + "hasServiceAgreement": null, + "beginTimestamp": null, + "term": null, + "termMonths": null, + "termDays": null + }, + "exercise": { + "frequency": "Weekly", + "nextStartTimestamp": "2025-04-19T10:00:00", + "mode": "Unloaded", + "runningMode": null, + "durationMinutes": 20, + "lastStartTimestamp": "2025-04-12T14:00:00+00:00", + "lastEndTimestamp": "2025-04-12T14:19:59+00:00" + }, + "lastRanTimestamp": "2025-04-12T14:00:00+00:00", + "totalRuntimeHours": 120.2, + "totalOperationHours": 33932.3, + "runtimeSinceLastMaintenanceHours": 0.3, + "remoteResetCounterSeconds": 0, + "addedBy": null, + "associatedUsers": ["pete.rage@rage.com"], + "controllerClockTimestamp": "2025-04-15T07:08:50", + "fuelType": "LiquidPropane", + "batteryVoltageV": 13.9, + "engineCoolantTempF": null, + "engineFrequencyHz": 0, + "engineSpeedRpm": 0, + "lubeOilTempF": 42.8, + "controllerTempF": 71.6, + "engineCompartmentTempF": null, + "engineOilPressurePsi": null, + "engineOilPressureOk": true, + "generatorLoadW": 0, + "generatorLoadPercent": 0, + "generatorVoltageAvgV": 0, + "setOutputVoltageV": 240, + "utilityVoltageV": 259.7, + "engineState": "Standby", + "engineStateDisplayNameEn": "Standby", + "loadShed": { + "isConnected": true, + "parameters": [ + { + "definitionId": 1, + "displayName": "HVAC A", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 2, + "displayName": "HVAC B", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 3, + "displayName": "Load A", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 4, + "displayName": "Load B", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 5, + "displayName": "Load C", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 6, + "displayName": "Load D", + "value": false, + "isReadOnly": false + } + ] + }, + "pim": { + "isConnected": false, + "parameters": [ + { + "definitionId": 7, + "displayName": "Digital Output B1 Value", + "value": false, + "isReadOnly": true + }, + { + "definitionId": 8, + "displayName": "Digital Output B2 Value", + "value": false, + "isReadOnly": true + }, + { + "definitionId": 9, + "displayName": "Digital Output B3 Value", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 10, + "displayName": "Digital Output B4 Value", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 11, + "displayName": "Digital Output B5 Value", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 12, + "displayName": "Digital Output B6 Value", + "value": false, + "isReadOnly": false + } + ] + } +} diff --git a/tests/components/rehlko/fixtures/homes.json b/tests/components/rehlko/fixtures/homes.json new file mode 100644 index 00000000000..5cd29e9111c --- /dev/null +++ b/tests/components/rehlko/fixtures/homes.json @@ -0,0 +1,82 @@ +[ + { + "id": 12345, + "name": "Generator 1", + "weatherCondition": "Mist", + "weatherTempF": 46.11200000000006, + "weatherTimePeriod": "Day", + "address": { + "lat": 41.334111, + "long": -72.3333111, + "address1": "Highway 66", + "address2": null, + "city": "Somewhere", + "state": "CA", + "postalCode": "000000", + "country": "US" + }, + "devices": [ + { + "id": 12345, + "serialNumber": "123MGVHR4567", + "displayName": "Generator 1", + "deviceHost": "Oncue", + "hasAcceptedPrivacyPolicy": true, + "address": { + "lat": 41.334111, + "long": -72.3333111, + "address1": "Highway 66", + "address2": null, + "city": "Somewhere", + "state": "CA", + "postalCode": "000000", + "country": "US" + }, + "product": "Rdc2v4", + "productDisplayName": "RDC 2.4", + "controllerType": "RDC2 (Blue Board)", + "firmwareVersion": "3.4.5", + "currentFirmware": "RDC2.4 3.4.5", + "isConnected": true, + "lastConnectedTimestamp": "2025-04-14T09:30:17+00:00", + "deviceIpAddress": "1.1.1.1:2402", + "macAddress": "91:E1:20:63:10:00", + "status": "ReadyToRun", + "statusUpdateTimestamp": "2025-04-14T09:29:01+00:00", + "dealerOrgs": [ + { + "id": 123, + "businessPartnerNo": "123456", + "name": "Generators R Us", + "e164PhoneNumber": "+199999999999", + "displayPhoneNumber": "(999) 999-9999", + "wizardStep": "OnboardingComplete", + "wizardComplete": true, + "address": { + "lat": null, + "long": null, + "address1": "Highway 66", + "address2": null, + "city": "Revisited", + "state": "CA", + "postalCode": "000000", + "country": null + }, + "userCount": 4, + "technicianCount": 3, + "deviceCount": 71, + "adminEmails": ["admin@gmail.com"] + } + ], + "alertCount": 0, + "model": "Model20KW", + "modelDisplayName": "20 KW", + "lastMaintenanceTimestamp": "2025-04-10T09:12:59", + "nextMaintenanceTimestamp": "2026-04-10T09:12:59", + "maintenancePeriodDays": 365, + "hasServiceAgreement": null, + "totalRuntimeHours": 120.2 + } + ] + } +] diff --git a/tests/components/rehlko/snapshots/test_sensor.ambr b/tests/components/rehlko/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..3973996ba80 --- /dev/null +++ b/tests/components/rehlko/snapshots/test_sensor.ambr @@ -0,0 +1,1017 @@ +# serializer version: 1 +# name: test_sensors[sensor.generator_1_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_battery_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': 'Battery voltage', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': 'myemail@email.com_12345_batteryVoltageV', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Generator 1 Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.9', + }) +# --- +# name: test_sensors[sensor.generator_1_controller_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_controller_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': 'Controller temperature', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'controller_temperature', + 'unique_id': 'myemail@email.com_12345_controllerTempF', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_controller_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Generator 1 Controller temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_controller_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_sensors[sensor.generator_1_device_ip_address-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_device_ip_address', + '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': 'Device IP address', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_ip_address', + 'unique_id': 'myemail@email.com_12345_deviceIpAddress', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_device_ip_address-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Device IP address', + }), + 'context': , + 'entity_id': 'sensor.generator_1_device_ip_address', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1.1.1:2402', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_compartment_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_engine_compartment_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': 'Engine compartment temperature', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_compartment_temperature', + 'unique_id': 'myemail@email.com_12345_engineCompartmentTempF', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_engine_compartment_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Generator 1 Engine compartment temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_compartment_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_coolant_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_engine_coolant_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': 'Engine coolant temperature', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_coolant_temperature', + 'unique_id': 'myemail@email.com_12345_engineCoolantTempF', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_engine_coolant_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Generator 1 Engine coolant temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_coolant_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_engine_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Engine frequency', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_frequency', + 'unique_id': 'myemail@email.com_12345_engineFrequencyHz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_engine_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Generator 1 Engine frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_oil_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_engine_oil_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Engine oil pressure', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_oil_pressure', + 'unique_id': 'myemail@email.com_12345_engineOilPressurePsi', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_engine_oil_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Generator 1 Engine oil pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_oil_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_engine_speed', + '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': 'Engine speed', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_speed', + 'unique_id': 'myemail@email.com_12345_engineSpeedRpm', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Engine speed', + 'state_class': , + 'unit_of_measurement': 'rpm', + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_engine_state', + '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': 'Engine state', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_state', + 'unique_id': 'myemail@email.com_12345_engineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_engine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Engine state', + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Standby', + }) +# --- +# name: test_sensors[sensor.generator_1_generator_load-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_generator_load', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Generator load', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_load', + 'unique_id': 'myemail@email.com_12345_generatorLoadW', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_generator_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Generator 1 Generator load', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_generator_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.generator_1_generator_load_percentage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_generator_load_percentage', + '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': 'Generator load percentage', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_load_percent', + 'unique_id': 'myemail@email.com_12345_generatorLoadPercent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.generator_1_generator_load_percentage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Generator load percentage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.generator_1_generator_load_percentage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.generator_1_generator_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_generator_status', + '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': 'Generator status', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_status', + 'unique_id': 'myemail@email.com_12345_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_generator_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Generator status', + }), + 'context': , + 'entity_id': 'sensor.generator_1_generator_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ReadyToRun', + }) +# --- +# name: test_sensors[sensor.generator_1_lube_oil_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_lube_oil_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': 'Lube oil temperature', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lube_oil_temperature', + 'unique_id': 'myemail@email.com_12345_lubeOilTempF', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_lube_oil_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Generator 1 Lube oil temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_lube_oil_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.0', + }) +# --- +# name: test_sensors[sensor.generator_1_power_source-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_power_source', + '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 source', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_source', + 'unique_id': 'myemail@email.com_12345_powerSource', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_power_source-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Power source', + }), + 'context': , + 'entity_id': 'sensor.generator_1_power_source', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Utility', + }) +# --- +# name: test_sensors[sensor.generator_1_runtime_since_last_maintenance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_runtime_since_last_maintenance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Runtime since last maintenance', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'runtime_since_last_maintenance', + 'unique_id': 'myemail@email.com_12345_runtimeSinceLastMaintenanceHours', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_runtime_since_last_maintenance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Generator 1 Runtime since last maintenance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_runtime_since_last_maintenance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensors[sensor.generator_1_server_ip_address-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_server_ip_address', + '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': 'Server IP address', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'server_ip_address', + 'unique_id': 'myemail@email.com_12345_serverIpAddress', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_server_ip_address-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Server IP address', + }), + 'context': , + 'entity_id': 'sensor.generator_1_server_ip_address', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2.2.2', + }) +# --- +# name: test_sensors[sensor.generator_1_total_operation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_total_operation', + '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 operation', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_operation', + 'unique_id': 'myemail@email.com_12345_totalOperationHours', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_total_operation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Generator 1 Total operation', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_total_operation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33932.3', + }) +# --- +# name: test_sensors[sensor.generator_1_total_runtime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_total_runtime', + '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 runtime', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_runtime', + 'unique_id': 'myemail@email.com_12345_totalRuntimeHours', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_total_runtime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Generator 1 Total runtime', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_total_runtime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120.2', + }) +# --- +# name: test_sensors[sensor.generator_1_utility_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_utility_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': 'Utility voltage', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'utility_voltage', + 'unique_id': 'myemail@email.com_12345_utilityVoltageV', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_utility_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Generator 1 Utility voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_utility_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '259.7', + }) +# --- +# name: test_sensors[sensor.generator_1_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_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': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_voltage_avg', + 'unique_id': 'myemail@email.com_12345_generatorVoltageAvgV', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Generator 1 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/rehlko/test_config_flow.py b/tests/components/rehlko/test_config_flow.py new file mode 100644 index 00000000000..6e3400941ab --- /dev/null +++ b/tests/components/rehlko/test_config_flow.py @@ -0,0 +1,218 @@ +"""Test the Rehlko config flow.""" + +from unittest.mock import AsyncMock + +from aiokem import AuthenticationCredentialsError +import pytest + +from homeassistant.components.rehlko import DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER +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 .conftest import TEST_EMAIL, TEST_PASSWORD, TEST_SUBJECT + +from tests.common import MockConfigEntry + +DHCP_DISCOVERY = DhcpServiceInfo( + ip="1.1.1.1", + hostname="KohlerGen", + macaddress="00146FAABBCC", +) + + +async def test_configure_entry( + hass: HomeAssistant, mock_rehlko: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test we can configure the entry.""" + 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_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_EMAIL.lower() + assert result["data"] == { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + } + assert result["result"].unique_id == TEST_SUBJECT + assert mock_setup_entry.call_count == 1 + + +@pytest.mark.parametrize( + ("error", "conf_error"), + [ + (AuthenticationCredentialsError, {CONF_PASSWORD: "invalid_auth"}), + (TimeoutError, {"base": "cannot_connect"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_configure_entry_exceptions( + hass: HomeAssistant, + mock_rehlko: AsyncMock, + error: Exception, + conf_error: dict[str, str], + mock_setup_entry: AsyncMock, +) -> None: + """Test we handle a variety of exceptions and recover by adding new entry.""" + # First try to authenticate and get an error + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_rehlko.authenticate.side_effect = error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == conf_error + assert mock_setup_entry.call_count == 0 + + # Now try to authenticate again and succeed + # This should create a new entry + mock_rehlko.authenticate.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_EMAIL.lower() + assert result["data"] == { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + } + assert result["result"].unique_id == TEST_SUBJECT + assert mock_setup_entry.call_count == 1 + + +async def test_already_configured( + hass: HomeAssistant, rehlko_config_entry: MockConfigEntry, mock_rehlko: AsyncMock +) -> None: + """Test if entry is already configured.""" + rehlko_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth( + hass: HomeAssistant, + rehlko_config_entry: MockConfigEntry, + mock_rehlko: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauth flow.""" + rehlko_config_entry.add_to_hass(hass) + result = await rehlko_config_entry.start_reauth_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: TEST_PASSWORD + "new", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert rehlko_config_entry.data[CONF_PASSWORD] == TEST_PASSWORD + "new" + assert mock_setup_entry.call_count == 1 + + +async def test_reauth_exception( + hass: HomeAssistant, + rehlko_config_entry: MockConfigEntry, + mock_rehlko: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauth flow.""" + rehlko_config_entry.add_to_hass(hass) + result = await rehlko_config_entry.start_reauth_flow(hass) + + mock_rehlko.authenticate.side_effect = AuthenticationCredentialsError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"password": "invalid_auth"} + + mock_rehlko.authenticate.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: TEST_PASSWORD + "new", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_dhcp_discovery( + hass: HomeAssistant, mock_rehlko: 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["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_dhcp_discovery_already_set_up( + hass: HomeAssistant, rehlko_config_entry: MockConfigEntry, mock_rehlko: AsyncMock +) -> None: + """Test DHCP discovery aborts if already set up.""" + rehlko_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" diff --git a/tests/components/rehlko/test_sensor.py b/tests/components/rehlko/test_sensor.py new file mode 100644 index 00000000000..ef3d9d1cf6a --- /dev/null +++ b/tests/components/rehlko/test_sensor.py @@ -0,0 +1,85 @@ +"""Tests for the Rehlko sensors.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.rehlko.coordinator import SCAN_INTERVAL_MINUTES +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(name="platform_sensor", autouse=True) +async def platform_sensor_fixture(): + """Patch Rehlko to only load Sensor platform.""" + with patch("homeassistant.components.rehlko.PLATFORMS", [Platform.SENSOR]): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + rehlko_config_entry: MockConfigEntry, + load_rehlko_config_entry: None, +) -> None: + """Test the Rehlko sensors.""" + await snapshot_platform( + hass, entity_registry, snapshot, rehlko_config_entry.entry_id + ) + + +async def test_sensor_availability_device_disconnect( + hass: HomeAssistant, + generator: dict[str, Any], + mock_rehlko: AsyncMock, + load_rehlko_config_entry: None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Rehlko sensor availability when device is disconnected.""" + state = hass.states.get("sensor.generator_1_battery_voltage") + assert state + assert state.state == "13.9" + + generator["device"]["isConnected"] = False + + # Move time to next update + freezer.tick(SCAN_INTERVAL_MINUTES) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.generator_1_battery_voltage") + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_sensor_availability_poll_failure( + hass: HomeAssistant, + mock_rehlko: AsyncMock, + load_rehlko_config_entry: None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Rehlko sensor availability when cloud poll fails.""" + state = hass.states.get("sensor.generator_1_battery_voltage") + assert state + assert state.state == "13.9" + + mock_rehlko.get_generator_data.side_effect = Exception("Test exception") + + # Move time to next update + freezer.tick(SCAN_INTERVAL_MINUTES) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.generator_1_battery_voltage") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/remote_calendar/snapshots/test_calendar.ambr b/tests/components/remote_calendar/snapshots/test_calendar.ambr new file mode 100644 index 00000000000..e372be5255c --- /dev/null +++ b/tests/components/remote_calendar/snapshots/test_calendar.ambr @@ -0,0 +1,19 @@ +# serializer version: 1 +# name: test_calendar_examples[office365_invalid_tzid] + list([ + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2024-04-26T15:00:00-06:00', + }), + 'location': '', + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-04-26T14:00:00-06:00', + }), + 'summary': 'Uffe', + 'uid': '040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000010000000309AE93C8C3A94489F90ADBEA30C2F2B', + }), + ]) +# --- diff --git a/tests/components/remote_calendar/test_calendar.py b/tests/components/remote_calendar/test_calendar.py index 6ae817321c3..a0c18383369 100644 --- a/tests/components/remote_calendar/test_calendar.py +++ b/tests/components/remote_calendar/test_calendar.py @@ -1,11 +1,13 @@ """Tests for calendar platform of Remote Calendar.""" from datetime import datetime +import pathlib import textwrap from httpx import Response import pytest import respx +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -21,6 +23,13 @@ from .conftest import ( from tests.common import MockConfigEntry +# Test data files with known calendars from various sources. You can add a new file +# in the testdata directory and add it will be parsed and tested. +TESTDATA_FILES = sorted( + pathlib.Path("tests/components/remote_calendar/testdata/").glob("*.ics") +) +TESTDATA_IDS = [f.stem for f in TESTDATA_FILES] + @respx.mock async def test_empty_calendar( @@ -392,3 +401,24 @@ async def test_all_day_iter_order( events = await get_events("2022-10-06T00:00:00Z", "2022-10-09T00:00:00Z") assert [event["summary"] for event in events] == event_order + + +@respx.mock +@pytest.mark.parametrize("ics_filename", TESTDATA_FILES, ids=TESTDATA_IDS) +async def test_calendar_examples( + hass: HomeAssistant, + config_entry: MockConfigEntry, + get_events: GetEventsFn, + ics_filename: pathlib.Path, + snapshot: SnapshotAssertion, +) -> None: + """Test parsing known calendars form test data files.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_filename.read_text(), + ) + ) + await setup_integration(hass, config_entry) + events = await get_events("1997-07-14T00:00:00", "2025-07-01T00:00:00") + assert events == snapshot diff --git a/tests/components/remote_calendar/testdata/office365_invalid_tzid.ics b/tests/components/remote_calendar/testdata/office365_invalid_tzid.ics new file mode 100644 index 00000000000..bfadba446d2 --- /dev/null +++ b/tests/components/remote_calendar/testdata/office365_invalid_tzid.ics @@ -0,0 +1,58 @@ +BEGIN:VCALENDAR +METHOD:PUBLISH +PRODID:Microsoft Exchange Server 2010 +VERSION:2.0 +X-WR-CALNAME:Kalender +BEGIN:VTIMEZONE +TZID:W. Europe Standard Time +BEGIN:STANDARD +DTSTART:16010101T030000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:16010101T020000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VTIMEZONE +TZID:UTC +BEGIN:STANDARD +DTSTART:16010101T000000 +TZOFFSETFROM:+0000 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:16010101T000000 +TZOFFSETFROM:+0000 +TZOFFSETTO:+0000 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000 + 010000000309AE93C8C3A94489F90ADBEA30C2F2B +SUMMARY:Uffe +DTSTART;TZID=Customized Time Zone:20240426T140000 +DTEND;TZID=Customized Time Zone:20240426T150000 +CLASS:PUBLIC +PRIORITY:5 +DTSTAMP:20250417T155647Z +TRANSP:OPAQUE +STATUS:CONFIRMED +SEQUENCE:0 +LOCATION: +X-MICROSOFT-CDO-APPT-SEQUENCE:0 +X-MICROSOFT-CDO-BUSYSTATUS:BUSY +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +X-MICROSOFT-CDO-IMPORTANCE:1 +X-MICROSOFT-CDO-INSTTYPE:0 +X-MICROSOFT-DONOTFORWARDMEETING:FALSE +X-MICROSOFT-DISALLOW-COUNTER:FALSE +X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT +X-MICROSOFT-ISRESPONSEREQUESTED:FALSE +END:VEVENT +END:VCALENDAR diff --git a/tests/components/renault/__init__.py b/tests/components/renault/__init__.py index a7c6b314ccb..b621d7d940c 100644 --- a/tests/components/renault/__init__.py +++ b/tests/components/renault/__init__.py @@ -1,101 +1 @@ """Tests for the Renault integration.""" - -from __future__ import annotations - -from types import MappingProxyType - -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_ICON, - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_MODEL_ID, - ATTR_NAME, - ATTR_STATE, - STATE_UNAVAILABLE, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceRegistry -from homeassistant.helpers.entity_registry import EntityRegistry - -from .const import ( - ATTR_UNIQUE_ID, - DYNAMIC_ATTRIBUTES, - FIXED_ATTRIBUTES, - ICON_FOR_EMPTY_VALUES, -) - - -def get_no_data_icon(expected_entity: MappingProxyType): - """Check icon attribute for inactive sensors.""" - entity_id = expected_entity[ATTR_ENTITY_ID] - return ICON_FOR_EMPTY_VALUES.get(entity_id, expected_entity.get(ATTR_ICON)) - - -def check_device_registry( - device_registry: DeviceRegistry, expected_device: MappingProxyType -) -> None: - """Ensure that the expected_device is correctly registered.""" - assert len(device_registry.devices) == 1 - registry_entry = device_registry.async_get_device( - identifiers=expected_device[ATTR_IDENTIFIERS] - ) - assert registry_entry is not None - assert registry_entry.identifiers == expected_device[ATTR_IDENTIFIERS] - assert registry_entry.manufacturer == expected_device[ATTR_MANUFACTURER] - assert registry_entry.name == expected_device[ATTR_NAME] - assert registry_entry.model == expected_device[ATTR_MODEL] - assert registry_entry.model_id == expected_device[ATTR_MODEL_ID] - - -def check_entities( - hass: HomeAssistant, - entity_registry: EntityRegistry, - expected_entities: MappingProxyType, -) -> None: - """Ensure that the expected_entities are correct.""" - for expected_entity in expected_entities: - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry is not None - assert registry_entry.unique_id == expected_entity[ATTR_UNIQUE_ID] - state = hass.states.get(entity_id) - assert state.state == expected_entity[ATTR_STATE] - for attr in FIXED_ATTRIBUTES + DYNAMIC_ATTRIBUTES: - assert state.attributes.get(attr) == expected_entity.get(attr) - - -def check_entities_no_data( - hass: HomeAssistant, - entity_registry: EntityRegistry, - expected_entities: MappingProxyType, - expected_state: str, -) -> None: - """Ensure that the expected_entities are correct.""" - for expected_entity in expected_entities: - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry is not None - assert registry_entry.unique_id == expected_entity[ATTR_UNIQUE_ID] - state = hass.states.get(entity_id) - assert state.state == expected_state - for attr in FIXED_ATTRIBUTES: - assert state.attributes.get(attr) == expected_entity.get(attr) - - -def check_entities_unavailable( - hass: HomeAssistant, - entity_registry: EntityRegistry, - expected_entities: MappingProxyType, -) -> None: - """Ensure that the expected_entities are correct.""" - for expected_entity in expected_entities: - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry is not None, f"{entity_id} not found in registry" - assert registry_entry.unique_id == expected_entity[ATTR_UNIQUE_ID] - state = hass.states.get(entity_id) - assert state.state == STATE_UNAVAILABLE - for attr in FIXED_ATTRIBUTES: - assert state.attributes.get(attr) == expected_entity.get(attr) diff --git a/tests/components/renault/conftest.py b/tests/components/renault/conftest.py index 9be41eb7ba0..ad968358c78 100644 --- a/tests/components/renault/conftest.py +++ b/tests/components/renault/conftest.py @@ -1,13 +1,12 @@ """Provide common Renault fixtures.""" -from collections.abc import Generator, Iterator +from collections.abc import AsyncGenerator, Generator import contextlib from types import MappingProxyType -from typing import Any from unittest.mock import AsyncMock, patch import pytest -from renault_api.kamereon import exceptions, schemas +from renault_api.kamereon import exceptions, models, schemas from renault_api.renault_account import RenaultAccount from homeassistant.components.renault.const import DOMAIN @@ -51,7 +50,7 @@ def get_config_entry(hass: HomeAssistant) -> ConfigEntry: @pytest.fixture(name="patch_renault_account") -async def patch_renault_account(hass: HomeAssistant) -> RenaultAccount: +async def patch_renault_account(hass: HomeAssistant) -> AsyncGenerator[RenaultAccount]: """Create a Renault account.""" renault_account = RenaultAccount( MOCK_ACCOUNT_ID, @@ -68,15 +67,27 @@ async def patch_renault_account(hass: HomeAssistant) -> RenaultAccount: @pytest.fixture(name="patch_get_vehicles") -def patch_get_vehicles(vehicle_type: str): +def patch_get_vehicles(vehicle_type: str) -> Generator[None]: """Mock fixtures.""" + fixture_code = vehicle_type if vehicle_type in MOCK_VEHICLES else "zoe_40" + return_value: models.KamereonVehiclesResponse = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture(f"renault/vehicle_{fixture_code}.json") + ) + ) + + if vehicle_type == "missing_details": + return_value.vehicleLinks[0].vehicleDetails = None + elif vehicle_type == "multi": + return_value.vehicleLinks.extend( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture("renault/vehicle_captur_fuel.json") + ).vehicleLinks + ) + with patch( "renault_api.renault_account.RenaultAccount.get_vehicles", - return_value=( - schemas.KamereonVehiclesResponseSchema.loads( - load_fixture(f"renault/vehicle_{vehicle_type}.json") - ) - ), + return_value=return_value, ): yield @@ -123,149 +134,100 @@ def _get_fixtures(vehicle_type: str) -> MappingProxyType: } +@contextlib.contextmanager +def patch_get_vehicle_data() -> Generator[dict[str, AsyncMock]]: + """Mock get_vehicle_data methods.""" + with ( + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_battery_status" + ) as get_battery_status, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode" + ) as get_charge_mode, + patch("renault_api.renault_vehicle.RenaultVehicle.get_cockpit") as get_cockpit, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status" + ) as get_hvac_status, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_location" + ) as get_location, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_lock_status" + ) as get_lock_status, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_res_state" + ) as get_res_state, + ): + yield { + "battery_status": get_battery_status, + "charge_mode": get_charge_mode, + "cockpit": get_cockpit, + "hvac_status": get_hvac_status, + "location": get_location, + "lock_status": get_lock_status, + "res_state": get_res_state, + } + + @pytest.fixture(name="fixtures_with_data") -def patch_fixtures_with_data(vehicle_type: str): +def patch_fixtures_with_data(vehicle_type: str) -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" mock_fixtures = _get_fixtures(vehicle_type) - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", - return_value=mock_fixtures["battery_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", - return_value=mock_fixtures["charge_mode"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", - return_value=mock_fixtures["cockpit"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", - return_value=mock_fixtures["hvac_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_location", - return_value=mock_fixtures["location"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_lock_status", - return_value=mock_fixtures["lock_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_res_state", - return_value=mock_fixtures["res_state"], - ), - ): - yield + with patch_get_vehicle_data() as patches: + for key, value in patches.items(): + value.return_value = mock_fixtures[key] + yield patches @pytest.fixture(name="fixtures_with_no_data") -def patch_fixtures_with_no_data(): +def patch_fixtures_with_no_data() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" mock_fixtures = _get_fixtures("") - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", - return_value=mock_fixtures["battery_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", - return_value=mock_fixtures["charge_mode"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", - return_value=mock_fixtures["cockpit"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", - return_value=mock_fixtures["hvac_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_location", - return_value=mock_fixtures["location"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_lock_status", - return_value=mock_fixtures["lock_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_res_state", - return_value=mock_fixtures["res_state"], - ), - ): - yield - - -@contextlib.contextmanager -def _patch_fixtures_with_side_effect(side_effect: Any) -> Iterator[None]: - """Mock fixtures.""" - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_location", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_lock_status", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_res_state", - side_effect=side_effect, - ), - ): - yield + with patch_get_vehicle_data() as patches: + for key, value in patches.items(): + value.return_value = mock_fixtures[key] + yield patches @pytest.fixture(name="fixtures_with_access_denied_exception") -def patch_fixtures_with_access_denied_exception(): +def patch_fixtures_with_access_denied_exception() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" access_denied_exception = exceptions.AccessDeniedException( "err.func.403", "Access is denied for this resource", ) - with _patch_fixtures_with_side_effect(access_denied_exception): - yield + with patch_get_vehicle_data() as patches: + for value in patches.values(): + value.side_effect = access_denied_exception + yield patches @pytest.fixture(name="fixtures_with_invalid_upstream_exception") -def patch_fixtures_with_invalid_upstream_exception(): +def patch_fixtures_with_invalid_upstream_exception() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" invalid_upstream_exception = exceptions.InvalidUpstreamException( "err.tech.500", "Invalid response from the upstream server (The request sent to the GDC is erroneous) ; 502 Bad Gateway", ) - with _patch_fixtures_with_side_effect(invalid_upstream_exception): - yield + with patch_get_vehicle_data() as patches: + for value in patches.values(): + value.side_effect = invalid_upstream_exception + yield patches @pytest.fixture(name="fixtures_with_not_supported_exception") -def patch_fixtures_with_not_supported_exception(): +def patch_fixtures_with_not_supported_exception() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" not_supported_exception = exceptions.NotSupportedException( "err.tech.501", "This feature is not technically supported by this gateway", ) - with _patch_fixtures_with_side_effect(not_supported_exception): - yield + with patch_get_vehicle_data() as patches: + for value in patches.values(): + value.side_effect = not_supported_exception + yield patches diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index c552321ef97..259d1b52f63 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -1,61 +1,7 @@ """Constants for the Renault integration tests.""" -from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.components.renault.const import ( - CONF_KAMEREON_ACCOUNT_ID, - CONF_LOCALE, - DOMAIN, -) -from homeassistant.components.select import ATTR_OPTIONS -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_ICON, - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_MODEL_ID, - ATTR_NAME, - ATTR_STATE, - ATTR_UNIT_OF_MEASUREMENT, - CONF_PASSWORD, - CONF_USERNAME, - PERCENTAGE, - STATE_NOT_HOME, - STATE_OFF, - STATE_ON, - STATE_UNKNOWN, - Platform, - UnitOfEnergy, - UnitOfLength, - UnitOfPower, - UnitOfTemperature, - UnitOfTime, - UnitOfVolume, -) - -ATTR_DEFAULT_DISABLED = "default_disabled" -ATTR_UNIQUE_ID = "unique_id" - -FIXED_ATTRIBUTES = ( - ATTR_DEVICE_CLASS, - ATTR_OPTIONS, - ATTR_STATE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, -) -DYNAMIC_ATTRIBUTES = (ATTR_ICON,) - -ICON_FOR_EMPTY_VALUES = { - "binary_sensor.reg_number_hvac": "mdi:fan-off", - "select.reg_number_charge_mode": "mdi:calendar-remove", - "sensor.reg_number_charge_state": "mdi:flash-off", - "sensor.reg_number_plug_state": "mdi:power-plug-off", -} +from homeassistant.components.renault.const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME MOCK_ACCOUNT_ID = "account_id_1" @@ -63,220 +9,20 @@ MOCK_ACCOUNT_ID = "account_id_1" MOCK_CONFIG = { CONF_USERNAME: "email@test.com", CONF_PASSWORD: "test", - CONF_KAMEREON_ACCOUNT_ID: "account_id_1", + CONF_KAMEREON_ACCOUNT_ID: MOCK_ACCOUNT_ID, CONF_LOCALE: "fr_FR", } MOCK_VEHICLES = { "zoe_40": { - "expected_device": { - ATTR_IDENTIFIERS: {(DOMAIN, "VF1AAAAA555777999")}, - ATTR_MANUFACTURER: "Renault", - ATTR_MODEL: "Zoe", - ATTR_NAME: "REG-NUMBER", - ATTR_MODEL_ID: "X101VE", - }, "endpoints": { "battery_status": "battery_status_charging.json", "charge_mode": "charge_mode_always.json", "cockpit": "cockpit_ev.json", "hvac_status": "hvac_status.1.json", }, - Platform.BINARY_SENSOR: [ - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PLUG, - ATTR_ENTITY_ID: "binary_sensor.reg_number_plug", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_plugged_in", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.BATTERY_CHARGING, - ATTR_ENTITY_ID: "binary_sensor.reg_number_charging", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging", - }, - { - ATTR_ENTITY_ID: "binary_sensor.reg_number_hvac", - ATTR_ICON: "mdi:fan-off", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_hvac_status", - }, - ], - Platform.BUTTON: [ - { - ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", - ATTR_ICON: "mdi:air-conditioner", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_air_conditioner", - }, - { - ATTR_ENTITY_ID: "button.reg_number_start_charge", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_charge", - }, - { - ATTR_ENTITY_ID: "button.reg_number_stop_charge", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_stop_charge", - }, - ], - Platform.DEVICE_TRACKER: [], - Platform.SELECT: [ - { - ATTR_ENTITY_ID: "select.reg_number_charge_mode", - ATTR_ICON: "mdi:calendar-remove", - ATTR_OPTIONS: [ - "always", - "always_charging", - "schedule_mode", - "scheduled", - ], - ATTR_STATE: "always", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charge_mode", - }, - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_battery_autonomy", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: "141", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_autonomy", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY, - ATTR_ENTITY_ID: "sensor.reg_number_battery_available_energy", - ATTR_STATE: "31", - ATTR_STATE_CLASS: SensorStateClass.TOTAL, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_available_energy", - ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY, - ATTR_ENTITY_ID: "sensor.reg_number_battery", - ATTR_STATE: "60", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_level", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - ATTR_ENTITY_ID: "sensor.reg_number_last_battery_activity", - ATTR_STATE: "2020-01-12T21:40:16+00:00", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_last_activity", - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.reg_number_battery_temperature", - ATTR_STATE: "20", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.reg_number_charge_state", - ATTR_ICON: "mdi:flash", - ATTR_OPTIONS: [ - "not_in_charge", - "waiting_for_a_planned_charge", - "charge_ended", - "waiting_for_current_charge", - "energy_flap_opened", - "charge_in_progress", - "charge_error", - "unavailable", - ], - ATTR_STATE: "charge_in_progress", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charge_state", - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.POWER, - ATTR_ENTITY_ID: "sensor.reg_number_charging_power", - ATTR_STATE: "0.027", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging_power", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DURATION, - ATTR_ENTITY_ID: "sensor.reg_number_charging_remaining_time", - ATTR_ICON: "mdi:timer", - ATTR_STATE: "145", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging_remaining_time", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.MINUTES, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_mileage", - ATTR_ICON: "mdi:sign-direction", - ATTR_STATE: "49114", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_mileage", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.reg_number_outside_temperature", - ATTR_STATE: "8.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_outside_temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_ENTITY_ID: "sensor.reg_number_hvac_soc_threshold", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_hvac_soc_threshold", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - ATTR_ENTITY_ID: "sensor.reg_number_last_hvac_activity", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_hvac_last_activity", - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.reg_number_plug_state", - ATTR_ICON: "mdi:power-plug", - ATTR_OPTIONS: [ - "unplugged", - "plugged", - "plugged_waiting_for_charge", - "plug_error", - "plug_unknown", - ], - ATTR_STATE: "plugged", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_plug_state", - }, - { - ATTR_ENTITY_ID: "sensor.reg_number_remote_engine_start", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_res_state", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.reg_number_remote_engine_start_code", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_res_state_code", - }, - ], }, "zoe_50": { - "expected_device": { - ATTR_IDENTIFIERS: {(DOMAIN, "VF1AAAAA555777999")}, - ATTR_MANUFACTURER: "Renault", - ATTR_MODEL: "Zoe", - ATTR_NAME: "REG-NUMBER", - ATTR_MODEL_ID: "X102VE", - }, "endpoints": { "battery_status": "battery_status_not_charging.json", "charge_mode": "charge_mode_schedule.json", @@ -286,251 +32,8 @@ MOCK_VEHICLES = { "lock_status": "lock_status.1.json", "res_state": "res_state.1.json", }, - Platform.BINARY_SENSOR: [ - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PLUG, - ATTR_ENTITY_ID: "binary_sensor.reg_number_plug", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_plugged_in", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.BATTERY_CHARGING, - ATTR_ENTITY_ID: "binary_sensor.reg_number_charging", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging", - }, - { - ATTR_ENTITY_ID: "binary_sensor.reg_number_hvac", - ATTR_ICON: "mdi:fan-off", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_hvac_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.LOCK, - ATTR_ENTITY_ID: "binary_sensor.reg_number_lock", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_lock_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_rear_left_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_rear_left_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_rear_right_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_rear_right_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_driver_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_driver_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_passenger_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_passenger_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_hatch", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_hatch_status", - }, - ], - Platform.BUTTON: [ - { - ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", - ATTR_ICON: "mdi:air-conditioner", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_air_conditioner", - }, - { - ATTR_ENTITY_ID: "button.reg_number_start_charge", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_charge", - }, - { - ATTR_ENTITY_ID: "button.reg_number_stop_charge", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_stop_charge", - }, - ], - Platform.DEVICE_TRACKER: [ - { - ATTR_ENTITY_ID: "device_tracker.reg_number_location", - ATTR_ICON: "mdi:car", - ATTR_STATE: STATE_NOT_HOME, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_location", - } - ], - Platform.SELECT: [ - { - ATTR_ENTITY_ID: "select.reg_number_charge_mode", - ATTR_ICON: "mdi:calendar-clock", - ATTR_OPTIONS: [ - "always", - "always_charging", - "schedule_mode", - "scheduled", - ], - ATTR_STATE: "schedule_mode", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charge_mode", - }, - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_battery_autonomy", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: "128", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_autonomy", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY, - ATTR_ENTITY_ID: "sensor.reg_number_battery_available_energy", - ATTR_STATE: "0", - ATTR_STATE_CLASS: SensorStateClass.TOTAL, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_available_energy", - ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY, - ATTR_ENTITY_ID: "sensor.reg_number_battery", - ATTR_STATE: "50", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_level", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - ATTR_ENTITY_ID: "sensor.reg_number_last_battery_activity", - ATTR_STATE: "2020-11-17T08:06:48+00:00", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_last_activity", - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.reg_number_battery_temperature", - ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.reg_number_charge_state", - ATTR_ICON: "mdi:flash-off", - ATTR_OPTIONS: [ - "not_in_charge", - "waiting_for_a_planned_charge", - "charge_ended", - "waiting_for_current_charge", - "energy_flap_opened", - "charge_in_progress", - "charge_error", - "unavailable", - ], - ATTR_STATE: "charge_error", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charge_state", - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.POWER, - ATTR_ENTITY_ID: "sensor.reg_number_admissible_charging_power", - ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging_power", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DURATION, - ATTR_ENTITY_ID: "sensor.reg_number_charging_remaining_time", - ATTR_ICON: "mdi:timer", - ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging_remaining_time", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.MINUTES, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_mileage", - ATTR_ICON: "mdi:sign-direction", - ATTR_STATE: "49114", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_mileage", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.reg_number_outside_temperature", - ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_outside_temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_ENTITY_ID: "sensor.reg_number_hvac_soc_threshold", - ATTR_STATE: "30.0", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_hvac_soc_threshold", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - ATTR_ENTITY_ID: "sensor.reg_number_last_hvac_activity", - ATTR_STATE: "2020-12-03T00:00:00+00:00", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_hvac_last_activity", - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.reg_number_plug_state", - ATTR_ICON: "mdi:power-plug-off", - ATTR_OPTIONS: [ - "unplugged", - "plugged", - "plugged_waiting_for_charge", - "plug_error", - "plug_unknown", - ], - ATTR_STATE: "unplugged", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_plug_state", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - ATTR_ENTITY_ID: "sensor.reg_number_last_location_activity", - ATTR_STATE: "2020-02-18T16:58:38+00:00", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_location_last_activity", - }, - { - ATTR_ENTITY_ID: "sensor.reg_number_remote_engine_start", - ATTR_STATE: "Stopped, ready for RES", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_res_state", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.reg_number_remote_engine_start_code", - ATTR_STATE: "10", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_res_state_code", - }, - ], }, "captur_phev": { - "expected_device": { - ATTR_IDENTIFIERS: {(DOMAIN, "VF1AAAAA555777123")}, - ATTR_MANUFACTURER: "Renault", - ATTR_MODEL: "Captur ii", - ATTR_NAME: "REG-NUMBER", - ATTR_MODEL_ID: "XJB1SU", - }, "endpoints": { "battery_status": "battery_status_charging.json", "charge_mode": "charge_mode_always.json", @@ -539,349 +42,22 @@ MOCK_VEHICLES = { "lock_status": "lock_status.1.json", "res_state": "res_state.1.json", }, - Platform.BINARY_SENSOR: [ - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PLUG, - ATTR_ENTITY_ID: "binary_sensor.reg_number_plug", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_plugged_in", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.BATTERY_CHARGING, - ATTR_ENTITY_ID: "binary_sensor.reg_number_charging", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_charging", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.LOCK, - ATTR_ENTITY_ID: "binary_sensor.reg_number_lock", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_lock_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_rear_left_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_rear_left_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_rear_right_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_rear_right_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_driver_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_driver_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_passenger_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_passenger_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_hatch", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_hatch_status", - }, - ], - Platform.BUTTON: [ - { - ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", - ATTR_ICON: "mdi:air-conditioner", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_start_air_conditioner", - }, - { - ATTR_ENTITY_ID: "button.reg_number_start_charge", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_start_charge", - }, - { - ATTR_ENTITY_ID: "button.reg_number_stop_charge", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_stop_charge", - }, - ], - Platform.DEVICE_TRACKER: [ - { - ATTR_ENTITY_ID: "device_tracker.reg_number_location", - ATTR_ICON: "mdi:car", - ATTR_STATE: STATE_NOT_HOME, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_location", - } - ], - Platform.SELECT: [ - { - ATTR_ENTITY_ID: "select.reg_number_charge_mode", - ATTR_ICON: "mdi:calendar-remove", - ATTR_OPTIONS: [ - "always", - "always_charging", - "schedule_mode", - "scheduled", - ], - ATTR_STATE: "always", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_charge_mode", - }, - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_battery_autonomy", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: "141", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_battery_autonomy", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY, - ATTR_ENTITY_ID: "sensor.reg_number_battery_available_energy", - ATTR_STATE: "31", - ATTR_STATE_CLASS: SensorStateClass.TOTAL, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_battery_available_energy", - ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY, - ATTR_ENTITY_ID: "sensor.reg_number_battery", - ATTR_STATE: "60", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_battery_level", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - ATTR_ENTITY_ID: "sensor.reg_number_last_battery_activity", - ATTR_STATE: "2020-01-12T21:40:16+00:00", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_battery_last_activity", - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.reg_number_battery_temperature", - ATTR_STATE: "20", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_battery_temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.reg_number_charge_state", - ATTR_ICON: "mdi:flash", - ATTR_OPTIONS: [ - "not_in_charge", - "waiting_for_a_planned_charge", - "charge_ended", - "waiting_for_current_charge", - "energy_flap_opened", - "charge_in_progress", - "charge_error", - "unavailable", - ], - ATTR_STATE: "charge_in_progress", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_charge_state", - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.POWER, - ATTR_ENTITY_ID: "sensor.reg_number_admissible_charging_power", - ATTR_STATE: "27.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_charging_power", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DURATION, - ATTR_ENTITY_ID: "sensor.reg_number_charging_remaining_time", - ATTR_ICON: "mdi:timer", - ATTR_STATE: "145", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_charging_remaining_time", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.MINUTES, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_fuel_autonomy", - ATTR_ICON: "mdi:gas-station", - ATTR_STATE: "35", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_fuel_autonomy", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.VOLUME, - ATTR_ENTITY_ID: "sensor.reg_number_fuel_quantity", - ATTR_ICON: "mdi:fuel", - ATTR_STATE: "3", - ATTR_STATE_CLASS: SensorStateClass.TOTAL, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_fuel_quantity", - ATTR_UNIT_OF_MEASUREMENT: UnitOfVolume.LITERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_mileage", - ATTR_ICON: "mdi:sign-direction", - ATTR_STATE: "5567", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_mileage", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.reg_number_plug_state", - ATTR_ICON: "mdi:power-plug", - ATTR_OPTIONS: [ - "unplugged", - "plugged", - "plugged_waiting_for_charge", - "plug_error", - "plug_unknown", - ], - ATTR_STATE: "plugged", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_plug_state", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - ATTR_ENTITY_ID: "sensor.reg_number_last_location_activity", - ATTR_STATE: "2020-02-18T16:58:38+00:00", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_location_last_activity", - }, - { - ATTR_ENTITY_ID: "sensor.reg_number_remote_engine_start", - ATTR_STATE: "Stopped, ready for RES", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_res_state", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.reg_number_remote_engine_start_code", - ATTR_STATE: "10", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_res_state_code", - }, - ], }, "captur_fuel": { - "expected_device": { - ATTR_IDENTIFIERS: {(DOMAIN, "VF1AAAAA555777123")}, - ATTR_MANUFACTURER: "Renault", - ATTR_MODEL: "Captur ii", - ATTR_NAME: "REG-NUMBER", - ATTR_MODEL_ID: "XJB1SU", - }, "endpoints": { "cockpit": "cockpit_fuel.json", "location": "location.json", "lock_status": "lock_status.1.json", "res_state": "res_state.1.json", }, - Platform.BINARY_SENSOR: [ - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.LOCK, - ATTR_ENTITY_ID: "binary_sensor.reg_number_lock", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_lock_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_rear_left_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_rear_left_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_rear_right_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_rear_right_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_driver_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_driver_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_passenger_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_passenger_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_hatch", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_hatch_status", - }, - ], - Platform.BUTTON: [ - { - ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", - ATTR_ICON: "mdi:air-conditioner", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_start_air_conditioner", - }, - ], - Platform.DEVICE_TRACKER: [ - { - ATTR_ENTITY_ID: "device_tracker.reg_number_location", - ATTR_ICON: "mdi:car", - ATTR_STATE: STATE_NOT_HOME, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_location", - } - ], - Platform.SELECT: [], - Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_fuel_autonomy", - ATTR_ICON: "mdi:gas-station", - ATTR_STATE: "35", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_fuel_autonomy", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.VOLUME, - ATTR_ENTITY_ID: "sensor.reg_number_fuel_quantity", - ATTR_ICON: "mdi:fuel", - ATTR_STATE: "3", - ATTR_STATE_CLASS: SensorStateClass.TOTAL, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_fuel_quantity", - ATTR_UNIT_OF_MEASUREMENT: UnitOfVolume.LITERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_mileage", - ATTR_ICON: "mdi:sign-direction", - ATTR_STATE: "5567", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_mileage", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - ATTR_ENTITY_ID: "sensor.reg_number_last_location_activity", - ATTR_STATE: "2020-02-18T16:58:38+00:00", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_location_last_activity", - }, - { - ATTR_ENTITY_ID: "sensor.reg_number_remote_engine_start", - ATTR_STATE: "Stopped, ready for RES", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_res_state", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.reg_number_remote_engine_start_code", - ATTR_STATE: "10", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_res_state_code", - }, - ], + }, + "twingo_3_electric": { + "endpoints": { + "battery_status": "battery_status_waiting_for_charger.json", + "charge_mode": "charge_mode_always.2.json", + "cockpit": "cockpit_ev.json", + "hvac_status": "hvac_status.3.json", + "location": "location.json", + }, }, } diff --git a/tests/components/renault/fixtures/battery_status_waiting_for_charger.json b/tests/components/renault/fixtures/battery_status_waiting_for_charger.json new file mode 100644 index 00000000000..a904de8627c --- /dev/null +++ b/tests/components/renault/fixtures/battery_status_waiting_for_charger.json @@ -0,0 +1,13 @@ +{ + "data": { + "id": "VF1AAAAA555777999", + "attributes": { + "timestamp": "2025-04-28T05:27:07Z", + "batteryLevel": 96, + "batteryAutonomy": 182, + "plugStatus": 3, + "chargingStatus": 0.3, + "chargingRemainingTime": 15 + } + } +} diff --git a/tests/components/renault/fixtures/charge_mode_always.2.json b/tests/components/renault/fixtures/charge_mode_always.2.json new file mode 100644 index 00000000000..c8c33942541 --- /dev/null +++ b/tests/components/renault/fixtures/charge_mode_always.2.json @@ -0,0 +1,9 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "chargeMode": "always_charging" + } + } +} diff --git a/tests/components/renault/fixtures/hvac_status.3.json b/tests/components/renault/fixtures/hvac_status.3.json new file mode 100644 index 00000000000..b0e5c2759e6 --- /dev/null +++ b/tests/components/renault/fixtures/hvac_status.3.json @@ -0,0 +1,11 @@ +{ + "data": { + "id": "VF1AAAAA555777999", + "attributes": { + "internalTemperature": 26.0, + "hvacStatus": "off", + "socThreshold": 30.0, + "lastUpdateTime": "2025-04-28T04:29:26Z" + } + } +} diff --git a/tests/components/renault/fixtures/vehicle_captur_fuel.json b/tests/components/renault/fixtures/vehicle_captur_fuel.json index 3aa854c61ea..b9c3c04b79c 100644 --- a/tests/components/renault/fixtures/vehicle_captur_fuel.json +++ b/tests/components/renault/fixtures/vehicle_captur_fuel.json @@ -4,7 +4,7 @@ "vehicleLinks": [ { "brand": "RENAULT", - "vin": "VF1AAAAA555777123", + "vin": "VF1CAPTURFUELVIN", "status": "ACTIVE", "linkType": "USER", "garageBrand": "RENAULT", @@ -19,7 +19,7 @@ "lastModifiedDate": "2020-06-15T06:20:39.107794Z" }, "vehicleDetails": { - "vin": "VF1AAAAA555777123", + "vin": "VF1CAPTURFUELVIN", "engineType": "H5H", "engineRatio": "470", "modelSCR": "CP1", @@ -76,7 +76,7 @@ "label": "ESSENCE", "group": "019" }, - "registrationNumber": "REG-NUMBER", + "registrationNumber": "REG-CAPTUR-FUEL", "vcd": "ADR00/DLIGM2/PGPRT2/FEUAR3/CDVIT1/SKTPOU/SKTPGR/SSCCPC/SSPREM/FDIU2/MAPSTD/RCALL/MET04/DANGMO/ECOMOD/SSRCAR/AIVCT/AVGSI/TPRPE/TSGNE/2TON/ITPK7/MLEXP1/SPERTA/SSPERG/SPERTP/VOLCHA/SREACT/AVOSP1/SWALBO/DWGE01/AVC1A/1234Y/AEBS07/PRAHL/AVCAM/STANDA/XJB/HJB/EA3/MF/ESS/DG/TEMP/TR4X2/AFURGE/RVDIST/ABS/SBARTO/CA02/TOPAN/PBNCH/LAC/VSTLAR/CPE/RET04/2RVLG/RALU17/CEAVRH/AIRBA2/SERIE/DRA/DRAP05/HARM01/ATAR03/SGAV02/SGAR02/BIXPE/BANAL/KM/TPRM3/AVREPL/SSDECA/SFIRBA/ABLAVI/ESPHSA/FPAS2/ALEVA/SCACBA/SOP03C/SSADPC/STHPLG/SKTGRV/VLCUIR/RETIN2/TRSEV1/REPNTC/LVAVIP/LVAREI/SASURV/KTGREP/SGACHA/BEL01/APL03/FSTPO/ALOUC5/CMAR3P/FIPOU2/NA406/BVA7/ECLHB4/RDIF10/PNSTRD/ISOFIX/ENPH01/HRGM01/SANFLT/CSRGAC/SANACF/SDPCLV/TLRP00/SPRODI/SAN613/AVFAP/AIRBDE/CHC03/E06T/SAN806/SSPTLP/SANCML/SSFLEX/SDRQAR/SEXTIN/M2019/PHAS1/SPRTQT/SAN913/STHABT/SSTYAD/HYB01/SSCABA/SANBAT/VEC012/XJB1SU/SSNBT/H5H", "assets": [ { diff --git a/tests/components/renault/fixtures/vehicle_captur_phev.json b/tests/components/renault/fixtures/vehicle_captur_phev.json index 03066c8238f..72d57af2b34 100644 --- a/tests/components/renault/fixtures/vehicle_captur_phev.json +++ b/tests/components/renault/fixtures/vehicle_captur_phev.json @@ -4,7 +4,7 @@ "vehicleLinks": [ { "brand": "RENAULT", - "vin": "VF1AAAAA555777123", + "vin": "VF1CAPTURPHEVVIN", "status": "ACTIVE", "linkType": "OWNER", "garageBrand": "RENAULT", @@ -19,7 +19,7 @@ "lastModifiedDate": "2020-10-08T17:36:39.445523Z" }, "vehicleDetails": { - "vin": "VF1AAAAA555777123", + "vin": "VF1CAPTURPHEVVIN", "registrationDate": "2020-09-30", "firstRegistrationDate": "2020-09-30", "engineType": "H4M", @@ -78,7 +78,7 @@ "label": "PETROL", "group": "019" }, - "registrationNumber": "REG-NUMBER", + "registrationNumber": "REG-CAPTUR_PHEV", "vcd": "STANDA/XJB/HJB/EA3/MM/ESS/DG/TEMP/TR4X2/AFURGE/RV/ABS/SBARTO/CA02/TN/PBNCH/LAC/VT/CPE/RET04/2RVLG/RALU17/CEAVRH/AIRBA2/SERIE/DRA/DRAP05/HARM01/ATAR03/SGAV01/SGAR02/BIYPC/BANAL/KM/TPRM3/AVREPL/SSDECA/SFIRBA/ABLAVI/ESPHSA/FPAS2/ALEVA/CACBL3/SOP03C/SSADPC/STHPLG/SKTGRV/VLCUIR/RETRCR/TRSEV1/REPNTC/LVAVIP/LVAREI/SASURV/KTGREP/SGSCHA/ITA01/APL03/FSTPO/ALOUC5/PART01/CMAR3P/FIPOU2/NA418/BVH4/ECLHB4/RDIF10/PNSTRD/ISOFIX/ENPH01/HRGM01/SANFLT/CSRFLY/SANACF/SDPCLV/TLRP00/SPRODI/SAN613/AVFAP/AIRBDE/CHC03/E06U/SAN806/SSPTLP/SANCML/SSFLEX/SDRQAR/SEXTIN/M2019/PHAS1/SPRTQT/SAN913/STHABT/5DHS/HYB06/010KWH/BT9AE1/VEC237/XJB1SU/NBT018/H4M/NOADR/DLIGM2/PGPRT2/FEUAR3/SCDVIT/SKTPOU/SKTPGR/SSCCPC/SSPREM/FDIU2/MAPSTD/RCALL/MET05/SDANGM/ECOMOD/SSRCAR/AIVCT/AVGSI/TPQNW/TSGNE/2TON/ITPK4/MLEXP1/SPERTA/SSPERG/SPERTP/VOLNCH/SREACT/AVTSR1/SWALBO/DWGE01/AVC1A/VSPTA/1234Y/AEBS07/PRAHL/RRCAM", "assets": [ { diff --git a/tests/components/renault/fixtures/vehicle_missing_details.json b/tests/components/renault/fixtures/vehicle_missing_details.json deleted file mode 100644 index f6467e0c8f8..00000000000 --- a/tests/components/renault/fixtures/vehicle_missing_details.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "accountId": "account-id-1", - "country": "FR", - "vehicleLinks": [ - { - "brand": "RENAULT", - "vin": "VF1AAAAA555777999", - "status": "ACTIVE", - "linkType": "OWNER", - "garageBrand": "RENAULT", - "annualMileage": 16000, - "mileage": 26464, - "startDate": "2017-08-07", - "createdDate": "2019-05-23T21:38:16.409008Z", - "lastModifiedDate": "2020-11-17T08:41:40.497400Z", - "ownershipStartDate": "2017-08-01", - "cancellationReason": {}, - "connectedDriver": { - "role": "MAIN_DRIVER", - "createdDate": "2019-06-17T09:49:06.880627Z", - "lastModifiedDate": "2019-06-17T09:49:06.880627Z" - } - } - ] -} diff --git a/tests/components/renault/fixtures/vehicle_twingo_3_electric.json b/tests/components/renault/fixtures/vehicle_twingo_3_electric.json new file mode 100644 index 00000000000..a19d6f196a0 --- /dev/null +++ b/tests/components/renault/fixtures/vehicle_twingo_3_electric.json @@ -0,0 +1,254 @@ +{ + "accountId": "account-id-1", + "country": "FR", + "vehicleLinks": [ + { + "brand": "RENAULT", + "vin": "VF1TWINGOIIIVIN", + "status": "ACTIVE", + "linkType": "OWNER", + "garageBrand": "renault", + "mileage": 23362, + "mileageUnit": "km", + "mileageDate": "2024-07-24", + "startDate": "2023-03-12", + "createdDate": "2023-03-11T23:53:55.253006Z", + "lastModifiedDate": "2024-07-24T15:13:28.062494Z", + "ownershipStartDate": "2023-03-07", + "cancellationReason": {}, + "connectedDriver": { + "role": "MAIN_DRIVER", + "createdDate": "2023-03-18T09:24:35.745983023Z", + "lastModifiedDate": "2023-03-18T09:24:35.745983023Z" + }, + "vehicleDetails": { + "vin": "VF1TWINGOIIIVIN", + "registrationDate": "2023-03-07", + "firstRegistrationDate": "2023-03-07", + "engineType": "5AL", + "engineRatio": "605", + "modelSCR": "2WE", + "passToSalesDate": "2023-02-10", + "deliveryCountry": { + "code": "FR", + "label": "FRANCE" + }, + "family": { + "code": "X07", + "label": "FAMILLE X07", + "group": "007" + }, + "tcu": { + "code": "AIVCT", + "label": "WITH AIVC CONNECTION UNIT", + "group": "E70" + }, + "navigationAssistanceLevel": { + "code": "SSNAV", + "label": "WITHOUT NAVIGATION ASSISTANCE", + "group": "408" + }, + "battery": { + "code": "BT6AE", + "label": "BT6AE BATTERY", + "group": "968" + }, + "radioType": { + "code": "NA435", + "label": "CORE NAV DAB - CLASS", + "group": "425" + }, + "registrationCountry": { + "code": "FR" + }, + "brand": { + "label": "RENAULT" + }, + "model": { + "code": "X071VE", + "label": "TWINGO III", + "group": "971" + }, + "gearbox": { + "code": "BVEL", + "label": "ELEC.VAR.GEARBOX", + "group": "427" + }, + "version": { + "code": "E3W A1E C1 X" + }, + "energy": { + "code": "ELEC", + "label": "ELECTRICITY", + "group": "019" + }, + "bodyType": { + "code": "B07", + "label": "5-DOOR X07 SALOON", + "group": "008" + }, + "steeringSide": { + "code": "DG", + "label": "LEFT-HAND DRIVE", + "group": "027" + }, + "registrationNumber": "REG-TWINGO-III", + "vcd": "STANDA/X07/B07/EA3/A1/ELEC/DG/TEMP/TR4X2/DA/RV/CAREG1/TOTOIL/LAC/VSTLAR/CPE/RET01/SPROJA/RALU15/CEAVFX/ADAC/CCHBAM/SERIE/DRA/TICUI6/HARM01/ATAR/SGAV02/FBANAR/OVRPP/BANAL/KM/TPRM3/VERCAP/SSDECA/ABLAV1/RDAR02/ALEVA/PRENFA/SOP02C/CTHAB2/VLCUIR/REPNTC/LVCIPE/KTGREP/SGSCHA/FRA01/APL03/BECQA1/PLAT02/VOLRH/SBRDA/PROJ1/SSNAV/NA435/BVEL/SSCAPO/STALT/SPREST/RANPAR/RDIF24/PRLOO1/PNSTRD/ISOFIA/ENPH02/HRGM01/SANACF/PREALA/CHARAP/TLFRAN/RGAR1/SPRODI/SAN613/SSFAP/SSABGE/SAN713/CHC03/ELC1/SANCML/PRUPT2/SSRESE/SSFLEX/M2021/PHAS1/SAN913/024KWH/BT6AE/VEC029/X071VE/NB005/5AL/SDLIGM/AVSVEL/RAGAC2/CDVOL1/COIN02/SKTPOU/SKTPGR/SSCCPC/SRGTLU/ELCTRI/SSTOST/SECAMH/FDIU1/SSESM/SRGPDB/SSCALL/FACBA1/SPRCIN/TABANA/CABDO1/AIVCT/PREVSE/TPRPP/TSRPP/1TON/SPERTA/PERB09/SPERTN/SPERTP/VOLNCH/SAFDEP/1234YF/SAACC1/COFMOF/SPMIR/SANVF/TCHQ0", + "manufacturingDate": "2023-02-10", + "assets": [ + { + "assetType": "PICTURE", + "viewpoint": "mybrand_2", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2" + }, + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE" + } + ], + "viewPointInLowerCase": "mybrand_2" + }, + { + "assetType": "PICTURE", + "viewpoint": "mybrand_5", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=RSITE&bookmark=EXT_34_AV&profile=HELIOS_OWNERSERVICES_SMALL_V2" + }, + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=RSITE&bookmark=EXT_34_AV&profile=HELIOS_OWNERSERVICES_LARGE" + } + ], + "viewPointInLowerCase": "mybrand_5" + }, + { + "assetType": "PICTURE", + "viewpoint": "myb_car_selector", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_RIGHT_SIDE&profile=HELIOS_OWNERSERVICES_SMALL_V2" + }, + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_RIGHT_SIDE&profile=HELIOS_OWNERSERVICES_LARGE" + } + ], + "viewPointInLowerCase": "myb_car_selector" + }, + { + "assetType": "PICTURE", + "viewpoint": "myb_car_page_dashboard", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_LEFT_SIDE&profile=HELIOS_OWNERSERVICES_SMALL_V2" + }, + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_LEFT_SIDE&profile=HELIOS_OWNERSERVICES_LARGE" + } + ], + "viewPointInLowerCase": "myb_car_page_dashboard" + }, + { + "assetType": "PICTURE", + "viewpoint": "myb_program_settings_page", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_LEFT_SIDE&profile=HELIOS_OWNERSERVICES_SMALL_V2" + }, + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_LEFT_SIDE&profile=HELIOS_OWNERSERVICES_LARGE" + } + ], + "viewPointInLowerCase": "myb_program_settings_page" + }, + { + "assetType": "PICTURE", + "viewpoint": "myb_plug_and_charge_activation", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_RIGHT_SIDE&profile=HELIOS_OWNERSERVICES_SMALL_V2" + }, + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_RIGHT_SIDE&profile=HELIOS_OWNERSERVICES_LARGE" + } + ], + "viewPointInLowerCase": "myb_plug_and_charge_activation" + }, + { + "assetType": "PICTURE", + "viewpoint": "myb_plug_and_charge_my_car", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_LEFT_SIDE&profile=HELIOS_OWNERSERVICES_SMALL_V2" + }, + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_LEFT_SIDE&profile=HELIOS_OWNERSERVICES_LARGE" + } + ], + "viewPointInLowerCase": "myb_plug_and_charge_my_car" + }, + { + "assetType": "URL", + "assetRole": "GUIDE", + "title": "e-guide", + "description": "", + "renditions": [ + { + "url": "https://tutos-videos.renault.fr/?id=twingo-electric", + "size": "51" + } + ] + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "Video 1", + "description": "", + "renditions": [ + { + "url": "1ChWFBuLqfU&t", + "size": "13" + } + ] + }, + { + "assetType": "URL", + "assetRole": "CAR", + "title": "More videos", + "description": "", + "renditions": [ + { + "url": "https://tutos-videos.renault.fr/?id=twingo-electric", + "size": "51" + } + ] + } + ], + "yearsOfMaintenance": 12, + "connectivityTechnology": "RLINK1", + "easyConnectStore": true, + "electrical": true, + "deliveryDate": "2023-03-21", + "retrievedFromDhs": false, + "engineEnergyType": "ELEC", + "radioCode": "", + "premiumSubscribed": false, + "batteryType": "NMC" + } + } + ] +} diff --git a/tests/components/renault/fixtures/vehicle_zoe_40.json b/tests/components/renault/fixtures/vehicle_zoe_40.json index ab80d586652..ea7faf4e109 100644 --- a/tests/components/renault/fixtures/vehicle_zoe_40.json +++ b/tests/components/renault/fixtures/vehicle_zoe_40.json @@ -4,7 +4,7 @@ "vehicleLinks": [ { "brand": "RENAULT", - "vin": "VF1AAAAA555777999", + "vin": "VF1ZOE40VIN", "status": "ACTIVE", "linkType": "OWNER", "garageBrand": "RENAULT", @@ -21,7 +21,7 @@ "lastModifiedDate": "2019-06-17T09:49:06.880627Z" }, "vehicleDetails": { - "vin": "VF1AAAAA555777999", + "vin": "VF1ZOE40VIN", "registrationDate": "2017-08-01", "firstRegistrationDate": "2017-08-01", "engineType": "5AQ", @@ -80,7 +80,7 @@ "label": "ELECTRIQUE", "group": "019" }, - "registrationNumber": "REG-NUMBER", + "registrationNumber": "REG-ZOE-40", "vcd": "SYTINC/SKTPOU/SAND41/FDIU1/SSESM/MAPSUP/SSCALL/SAND88/SAND90/SQKDRO/SDIFPA/FACBA2/PRLEX1/SSRCAR/CABDO2/TCU0G2/SWALBO/EVTEC1/STANDA/X10/B10/EA2/MB/ELEC/DG/TEMP/TR4X2/RV/ABS/CAREG/LAC/VT003/CPE/RET03/SPROJA/RALU16/CEAVRH/AIRBA1/SERIE/DRA/DRAP08/HARM02/ATAR/TERQG/SFBANA/KM/DPRPN/AVREPL/SSDECA/ASRESP/RDAR02/ALEVA/CACBL2/SOP02C/CTHAB2/TRNOR/LVAVIP/LVAREL/SASURV/KTGREP/SGSCHA/APL03/ALOUCC/CMAR3P/NAV3G5/RAD37A/BVEL/AUTAUG/RNORM/ISOFIX/EQPEUR/HRGM01/SDPCLV/TLFRAN/SPRODI/SAN613/SSAPEX/GENEV1/ELC1/SANCML/PE2012/PHAS1/SAN913/045KWH/BT4AR1/VEC153/X101VE/NBT017/5AQ", "assets": [ { diff --git a/tests/components/renault/fixtures/vehicle_zoe_50.json b/tests/components/renault/fixtures/vehicle_zoe_50.json index 560b2a2246a..50bdd4181af 100644 --- a/tests/components/renault/fixtures/vehicle_zoe_50.json +++ b/tests/components/renault/fixtures/vehicle_zoe_50.json @@ -113,7 +113,7 @@ "yearsOfMaintenance": 12, "rlinkStore": false, "radioCode": "1234", - "registrationNumber": "REG-NUMBER", + "registrationNumber": "REG-ZOE-50", "modelSCR": "ZOE", "easyConnectStore": false, "engineRatio": "605", @@ -122,7 +122,7 @@ "code": "BT4AR1", "label": "BATTERIE BT4AR1" }, - "vin": "VF1AAAAA555777999", + "vin": "VF1ZOE50VIN", "retrievedFromDhs": false, "vcd": "ASCOD0/DLIGM2/SSTINC/KITPOU/SKTPGR/SSCCPC/SDPSEC/FDIU2/SSMAP/SSCALL/FACBA1/DANGMO/SSRCAR/SSCABD/AIVCT/AVGSI/ITPK4/VOLNCH/REACTI/AVOSP1/SWALBO/SSDWGE/1234Y/SSAEBS/PRAHL/RRCAM/STANDA/X10/B10/EA3/MD/ELEC/DG/TEMP/TR4X2/AFURGE/RV/ABS/CAREG/LAC/VSTLAR/CPETIR/RET03/PROJAB/RALU16/CEAVRH/ADAC/AIRBA2/SERIE/DRA/DRAP13/HARM02/3ATRPH/SGAV01/BARRAB/TELNJ/SFBANA/KM/DPRPN/AVREPL/SSDECA/ABLAV/ASRESP/ALEVA/SCACBA/SOP02C/STHPLG/SKTGRV/VLCUIR/RETRCR/TRSEV1/RETC/LVAVIP/LVAREL/SASURV/KTGREP/SGSCHA/FRA01/APL03/FSTPO/ALOUC5/CMAR3P/SAN408/NA418/BVEL/AUTAUG/SPREST/RDIF01/ISOFIX/EQPEUR/HRGM01/SDPCLV/CHASTD/TL01A/SPRODI/SAN613/AIRBDE/PSMREC/ELC1/SSPTLP/SANCML/SEXTIN/PE2019/PHAS2/SAN913/THABT2/SSTYAD/SSHYB/052KWH/BT4AR1/VEC018/X102VE/NBT022/5AQ", "firstRegistrationDate": "2020-01-13", @@ -149,7 +149,7 @@ "lastModifiedDate": "2020-08-22T09:41:53.477398Z", "createdDate": "2020-08-22T09:41:53.477398Z" }, - "vin": "VF1AAAAA555777999", + "vin": "VF1ZOE50VIN", "lastModifiedDate": "2020-11-29T22:01:21.162572Z", "brand": "RENAULT", "startDate": "2020-08-21", diff --git a/tests/components/renault/snapshots/test_binary_sensor.ambr b/tests/components/renault/snapshots/test_binary_sensor.ambr index b62cfb4d1b1..e89873593e9 100644 --- a/tests/components/renault/snapshots/test_binary_sensor.ambr +++ b/tests/components/renault/snapshots/test_binary_sensor.ambr @@ -1,2629 +1,1388 @@ # serializer version: 1 -# name: test_binary_sensor_empty[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensor_empty[zoe_40][binary_sensor.reg_zoe_40_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_charging', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensor_empty[captur_fuel].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_lock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Lock', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_lock_status', - 'unit_of_measurement': None, +# name: test_binary_sensor_empty[zoe_40][binary_sensor.reg_zoe_40_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-ZOE-40 Charging', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_hatch', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hatch', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hatch_status', - 'unique_id': 'vf1aaaaa555777123_hatch_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_left_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 left door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_left_door_status', - 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_right_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 right door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_right_door_status', - 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'Driver door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'driver_door_status', - 'unique_id': 'vf1aaaaa555777123_driver_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'Passenger door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'passenger_door_status', - 'unique_id': 'vf1aaaaa555777123_passenger_door_status', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_binary_sensor_empty[captur_fuel].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'lock', - 'friendly_name': 'REG-NUMBER Lock', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_binary_sensor_empty[zoe_40][binary_sensor.reg_zoe_40_hvac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Hatch', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hatch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_hvac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear left door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_left_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear right door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_right_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Driver door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_driver_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Passenger door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_passenger_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1zoe40vin_hvac_status', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensor_empty[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensor_empty[zoe_40][binary_sensor.reg_zoe_40_hvac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 HVAC', }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_binary_sensor_empty[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_plug', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Plug', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_plugged_in', - 'unit_of_measurement': None, +# name: test_binary_sensor_empty[zoe_40][binary_sensor.reg_zoe_40_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_charging', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_lock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Lock', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_lock_status', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_hatch', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hatch', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hatch_status', - 'unique_id': 'vf1aaaaa555777123_hatch_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_left_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 left door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_left_door_status', - 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_right_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 right door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_right_door_status', - 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'Driver door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'driver_door_status', - 'unique_id': 'vf1aaaaa555777123_driver_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'Passenger door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'passenger_door_status', - 'unique_id': 'vf1aaaaa555777123_passenger_door_status', - 'unit_of_measurement': None, - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_plugged_in', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensor_empty[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'plug', - 'friendly_name': 'REG-NUMBER Plug', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_plug', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_binary_sensor_empty[zoe_40][binary_sensor.reg_zoe_40_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-ZOE-40 Plug', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'REG-NUMBER Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'lock', - 'friendly_name': 'REG-NUMBER Lock', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Hatch', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hatch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear left door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_left_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear right door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_right_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Driver door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_driver_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Passenger door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_passenger_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_binary_sensor_empty[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensor_errors[zoe_40][binary_sensor.reg_zoe_40_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_charging', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensor_empty[zoe_40].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_plug', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Plug', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_plugged_in', - 'unit_of_measurement': None, +# name: test_binary_sensor_errors[zoe_40][binary_sensor.reg_zoe_40_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-ZOE-40 Charging', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_charging', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'HVAC', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_status', - 'unique_id': 'vf1aaaaa555777999_hvac_status', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) # --- -# name: test_binary_sensor_empty[zoe_40].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'plug', - 'friendly_name': 'REG-NUMBER Plug', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_plug', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_binary_sensor_errors[zoe_40][binary_sensor.reg_zoe_40_hvac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'REG-NUMBER Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_hvac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER HVAC', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hvac', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1zoe40vin_hvac_status', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensor_empty[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensor_errors[zoe_40][binary_sensor.reg_zoe_40_hvac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 HVAC', }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) # --- -# name: test_binary_sensor_empty[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_plug', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Plug', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_plugged_in', - 'unit_of_measurement': None, +# name: test_binary_sensor_errors[zoe_40][binary_sensor.reg_zoe_40_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_charging', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'HVAC', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_status', - 'unique_id': 'vf1aaaaa555777999_hvac_status', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_lock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Lock', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_lock_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_hatch', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hatch', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hatch_status', - 'unique_id': 'vf1aaaaa555777999_hatch_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_left_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 left door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_left_door_status', - 'unique_id': 'vf1aaaaa555777999_rear_left_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_right_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 right door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_right_door_status', - 'unique_id': 'vf1aaaaa555777999_rear_right_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'Driver door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'driver_door_status', - 'unique_id': 'vf1aaaaa555777999_driver_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'Passenger door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'passenger_door_status', - 'unique_id': 'vf1aaaaa555777999_passenger_door_status', - 'unit_of_measurement': None, - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_plugged_in', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensor_empty[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'plug', - 'friendly_name': 'REG-NUMBER Plug', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_plug', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_binary_sensor_errors[zoe_40][binary_sensor.reg_zoe_40_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-ZOE-40 Plug', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'REG-NUMBER Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER HVAC', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hvac', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'lock', - 'friendly_name': 'REG-NUMBER Lock', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Hatch', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hatch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear left door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_left_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear right door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_right_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Driver door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_driver_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Passenger door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_passenger_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) # --- -# name: test_binary_sensors[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_driver_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_fuel_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': 'Driver door', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'driver_door_status', + 'unique_id': 'vf1capturfuelvin_driver_door_status', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[captur_fuel].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_lock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Lock', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_lock_status', - 'unit_of_measurement': None, +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_driver_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR-FUEL Driver door', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_hatch', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hatch', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hatch_status', - 'unique_id': 'vf1aaaaa555777123_hatch_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_left_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 left door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_left_door_status', - 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_right_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 right door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_right_door_status', - 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'Driver door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'driver_door_status', - 'unique_id': 'vf1aaaaa555777123_driver_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'Passenger door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'passenger_door_status', - 'unique_id': 'vf1aaaaa555777123_passenger_door_status', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_captur_fuel_driver_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[captur_fuel].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'lock', - 'friendly_name': 'REG-NUMBER Lock', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_hatch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Hatch', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hatch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_fuel_hatch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear left door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_left_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear right door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_right_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Driver door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_driver_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Passenger door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_passenger_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hatch', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hatch_status', + 'unique_id': 'vf1capturfuelvin_hatch_status', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_hatch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR-FUEL Hatch', }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_captur_fuel_hatch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_plug', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Plug', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_plugged_in', - 'unit_of_measurement': None, +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_charging', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_fuel_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_lock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Lock', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_lock_status', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_hatch', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hatch', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hatch_status', - 'unique_id': 'vf1aaaaa555777123_hatch_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_left_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 left door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_left_door_status', - 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_right_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 right door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_right_door_status', - 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'Driver door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'driver_door_status', - 'unique_id': 'vf1aaaaa555777123_driver_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'Passenger door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'passenger_door_status', - 'unique_id': 'vf1aaaaa555777123_passenger_door_status', - 'unit_of_measurement': None, - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1capturfuelvin_lock_status', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'plug', - 'friendly_name': 'REG-NUMBER Plug', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_plug', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'REG-CAPTUR-FUEL Lock', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'REG-NUMBER Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'lock', - 'friendly_name': 'REG-NUMBER Lock', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Hatch', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hatch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear left door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_left_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear right door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_right_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Driver door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_driver_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Passenger door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_passenger_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_captur_fuel_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_passenger_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_fuel_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': 'Passenger door', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'passenger_door_status', + 'unique_id': 'vf1capturfuelvin_passenger_door_status', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[zoe_40].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_plug', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Plug', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_plugged_in', - 'unit_of_measurement': None, +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_passenger_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR-FUEL Passenger door', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_charging', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'HVAC', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_status', - 'unique_id': 'vf1aaaaa555777999_hvac_status', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_captur_fuel_passenger_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[zoe_40].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'plug', - 'friendly_name': 'REG-NUMBER Plug', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_plug', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_rear_left_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'REG-NUMBER Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_fuel_rear_left_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER HVAC', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hvac', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left door', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rear_left_door_status', + 'unique_id': 'vf1capturfuelvin_rear_left_door_status', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_rear_left_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR-FUEL Rear left door', }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_captur_fuel_rear_left_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_plug', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Plug', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_plugged_in', - 'unit_of_measurement': None, +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_rear_right_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_charging', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_fuel_rear_right_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'HVAC', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_status', - 'unique_id': 'vf1aaaaa555777999_hvac_status', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_lock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Lock', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_lock_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_hatch', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hatch', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hatch_status', - 'unique_id': 'vf1aaaaa555777999_hatch_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_left_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 left door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_left_door_status', - 'unique_id': 'vf1aaaaa555777999_rear_left_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_right_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 right door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_right_door_status', - 'unique_id': 'vf1aaaaa555777999_rear_right_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'Driver door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'driver_door_status', - 'unique_id': 'vf1aaaaa555777999_driver_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'Passenger door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'passenger_door_status', - 'unique_id': 'vf1aaaaa555777999_passenger_door_status', - 'unit_of_measurement': None, - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right door', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rear_right_door_status', + 'unique_id': 'vf1capturfuelvin_rear_right_door_status', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'plug', - 'friendly_name': 'REG-NUMBER Plug', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_plug', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_rear_right_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR-FUEL Rear right door', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'REG-NUMBER Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER HVAC', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hvac', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'lock', - 'friendly_name': 'REG-NUMBER Lock', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Hatch', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hatch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear left door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_left_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear right door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_right_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Driver door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_driver_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Passenger door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_passenger_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_captur_fuel_rear_right_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_phev_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1capturphevvin_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-CAPTUR_PHEV Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_captur_phev_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_driver_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_phev_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': 'Driver door', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'driver_door_status', + 'unique_id': 'vf1capturphevvin_driver_door_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_driver_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR_PHEV Driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_captur_phev_driver_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_hatch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_phev_hatch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hatch', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hatch_status', + 'unique_id': 'vf1capturphevvin_hatch_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_hatch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR_PHEV Hatch', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_captur_phev_hatch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_phev_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1capturphevvin_lock_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'REG-CAPTUR_PHEV Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_captur_phev_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_passenger_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_phev_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': 'Passenger door', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'passenger_door_status', + 'unique_id': 'vf1capturphevvin_passenger_door_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_passenger_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR_PHEV Passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_captur_phev_passenger_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_phev_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1capturphevvin_plugged_in', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-CAPTUR_PHEV Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_captur_phev_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_rear_left_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_phev_rear_left_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 left door', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rear_left_door_status', + 'unique_id': 'vf1capturphevvin_rear_left_door_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_rear_left_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR_PHEV Rear left door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_captur_phev_rear_left_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_rear_right_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_phev_rear_right_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 right door', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rear_right_door_status', + 'unique_id': 'vf1capturphevvin_rear_right_door_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_rear_right_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR_PHEV Rear right door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_captur_phev_rear_right_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_twingo_iii_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1twingoiiivin_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-TWINGO-III Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_twingo_iii_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_hvac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_twingo_iii_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': 'HVAC', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1twingoiiivin_hvac_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_hvac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-TWINGO-III HVAC', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_twingo_iii_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_twingo_iii_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1twingoiiivin_plugged_in', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-TWINGO-III Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_twingo_iii_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[zoe_40][binary_sensor.reg_zoe_40_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[zoe_40][binary_sensor.reg_zoe_40_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-ZOE-40 Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[zoe_40][binary_sensor.reg_zoe_40_hvac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_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': 'HVAC', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1zoe40vin_hvac_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[zoe_40][binary_sensor.reg_zoe_40_hvac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 HVAC', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[zoe_40][binary_sensor.reg_zoe_40_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_plugged_in', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[zoe_40][binary_sensor.reg_zoe_40_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-ZOE-40 Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_50_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe50vin_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-ZOE-50 Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_50_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_hvac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_50_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': 'HVAC', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1zoe50vin_hvac_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_hvac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 HVAC', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_50_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_50_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe50vin_plugged_in', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-ZOE-50 Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_50_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- diff --git a/tests/components/renault/snapshots/test_button.ambr b/tests/components/renault/snapshots/test_button.ambr index 58789c7aa47..1c7d5f80af2 100644 --- a/tests/components/renault/snapshots/test_button.ambr +++ b/tests/components/renault/snapshots/test_button.ambr @@ -1,1205 +1,1176 @@ # serializer version: 1 -# name: test_button_empty[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_button_access_denied[zoe_40][button.reg_zoe_40_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + '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': 'Start air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1zoe40vin_start_air_conditioner', + 'unit_of_measurement': None, + }) # --- -# name: test_button_empty[captur_fuel].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_air_conditioner', - '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': 'Start air conditioner', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_air_conditioner', - 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', - 'unit_of_measurement': None, +# name: test_button_access_denied[zoe_40][button.reg_zoe_40_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start air conditioner', }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_button_empty[captur_fuel].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start air conditioner', - }), - 'context': , - 'entity_id': 'button.reg_number_start_air_conditioner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_button_access_denied[zoe_40][button.reg_zoe_40_start_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_charge', + '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': 'Start charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1zoe40vin_start_charge', + 'unit_of_measurement': None, + }) # --- -# name: test_button_empty[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_button_access_denied[zoe_40][button.reg_zoe_40_start_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start charge', }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_start_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_button_empty[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_air_conditioner', - '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': 'Start air conditioner', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_air_conditioner', - 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', - 'unit_of_measurement': None, +# name: test_button_access_denied[zoe_40][button.reg_zoe_40_stop_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_charge', - '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': 'Start charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_charge', - 'unique_id': 'vf1aaaaa555777123_start_charge', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_stop_charge', - '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': 'Stop charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'stop_charge', - 'unique_id': 'vf1aaaaa555777123_stop_charge', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1zoe40vin_stop_charge', + 'unit_of_measurement': None, + }) # --- -# name: test_button_empty[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start air conditioner', - }), - 'context': , - 'entity_id': 'button.reg_number_start_air_conditioner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_button_access_denied[zoe_40][button.reg_zoe_40_stop_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Stop charge', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start charge', - }), - 'context': , - 'entity_id': 'button.reg_number_start_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Stop charge', - }), - 'context': , - 'entity_id': 'button.reg_number_stop_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_button_empty[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_button_empty[zoe_40][button.reg_zoe_40_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + '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': 'Start air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1zoe40vin_start_air_conditioner', + 'unit_of_measurement': None, + }) # --- -# name: test_button_empty[zoe_40].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_air_conditioner', - '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': 'Start air conditioner', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_air_conditioner', - 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', - 'unit_of_measurement': None, +# name: test_button_empty[zoe_40][button.reg_zoe_40_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start air conditioner', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_charge', - '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': 'Start charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_charge', - 'unique_id': 'vf1aaaaa555777999_start_charge', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_stop_charge', - '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': 'Stop charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'stop_charge', - 'unique_id': 'vf1aaaaa555777999_stop_charge', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_button_empty[zoe_40].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start air conditioner', - }), - 'context': , - 'entity_id': 'button.reg_number_start_air_conditioner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_button_empty[zoe_40][button.reg_zoe_40_start_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start charge', - }), - 'context': , - 'entity_id': 'button.reg_number_start_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Stop charge', - }), - 'context': , - 'entity_id': 'button.reg_number_stop_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Start charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1zoe40vin_start_charge', + 'unit_of_measurement': None, + }) # --- -# name: test_button_empty[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_button_empty[zoe_40][button.reg_zoe_40_start_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start charge', }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_start_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_button_empty[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_air_conditioner', - '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': 'Start air conditioner', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_air_conditioner', - 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', - 'unit_of_measurement': None, +# name: test_button_empty[zoe_40][button.reg_zoe_40_stop_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_charge', - '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': 'Start charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_charge', - 'unique_id': 'vf1aaaaa555777999_start_charge', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_stop_charge', - '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': 'Stop charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'stop_charge', - 'unique_id': 'vf1aaaaa555777999_stop_charge', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1zoe40vin_stop_charge', + 'unit_of_measurement': None, + }) # --- -# name: test_button_empty[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start air conditioner', - }), - 'context': , - 'entity_id': 'button.reg_number_start_air_conditioner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_button_empty[zoe_40][button.reg_zoe_40_stop_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Stop charge', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start charge', - }), - 'context': , - 'entity_id': 'button.reg_number_start_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Stop charge', - }), - 'context': , - 'entity_id': 'button.reg_number_stop_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_buttons[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_button_errors[zoe_40][button.reg_zoe_40_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + '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': 'Start air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1zoe40vin_start_air_conditioner', + 'unit_of_measurement': None, + }) # --- -# name: test_buttons[captur_fuel].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_air_conditioner', - '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': 'Start air conditioner', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_air_conditioner', - 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', - 'unit_of_measurement': None, +# name: test_button_errors[zoe_40][button.reg_zoe_40_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start air conditioner', }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_buttons[captur_fuel].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start air conditioner', - }), - 'context': , - 'entity_id': 'button.reg_number_start_air_conditioner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_button_errors[zoe_40][button.reg_zoe_40_start_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_charge', + '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': 'Start charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1zoe40vin_start_charge', + 'unit_of_measurement': None, + }) # --- -# name: test_buttons[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_button_errors[zoe_40][button.reg_zoe_40_start_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start charge', }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_start_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_buttons[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_air_conditioner', - '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': 'Start air conditioner', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_air_conditioner', - 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', - 'unit_of_measurement': None, +# name: test_button_errors[zoe_40][button.reg_zoe_40_stop_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_charge', - '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': 'Start charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_charge', - 'unique_id': 'vf1aaaaa555777123_start_charge', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_stop_charge', - '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': 'Stop charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'stop_charge', - 'unique_id': 'vf1aaaaa555777123_stop_charge', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1zoe40vin_stop_charge', + 'unit_of_measurement': None, + }) # --- -# name: test_buttons[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start air conditioner', - }), - 'context': , - 'entity_id': 'button.reg_number_start_air_conditioner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_button_errors[zoe_40][button.reg_zoe_40_stop_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Stop charge', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start charge', - }), - 'context': , - 'entity_id': 'button.reg_number_start_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Stop charge', - }), - 'context': , - 'entity_id': 'button.reg_number_stop_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_buttons[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_button_not_supported[zoe_40][button.reg_zoe_40_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + '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': 'Start air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1zoe40vin_start_air_conditioner', + 'unit_of_measurement': None, + }) # --- -# name: test_buttons[zoe_40].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_air_conditioner', - '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': 'Start air conditioner', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_air_conditioner', - 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', - 'unit_of_measurement': None, +# name: test_button_not_supported[zoe_40][button.reg_zoe_40_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start air conditioner', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_charge', - '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': 'Start charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_charge', - 'unique_id': 'vf1aaaaa555777999_start_charge', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_stop_charge', - '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': 'Stop charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'stop_charge', - 'unique_id': 'vf1aaaaa555777999_stop_charge', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_buttons[zoe_40].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start air conditioner', - }), - 'context': , - 'entity_id': 'button.reg_number_start_air_conditioner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_button_not_supported[zoe_40][button.reg_zoe_40_start_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start charge', - }), - 'context': , - 'entity_id': 'button.reg_number_start_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Stop charge', - }), - 'context': , - 'entity_id': 'button.reg_number_stop_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Start charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1zoe40vin_start_charge', + 'unit_of_measurement': None, + }) # --- -# name: test_buttons[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_button_not_supported[zoe_40][button.reg_zoe_40_start_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start charge', }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_start_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_buttons[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_air_conditioner', - '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': 'Start air conditioner', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_air_conditioner', - 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', - 'unit_of_measurement': None, +# name: test_button_not_supported[zoe_40][button.reg_zoe_40_stop_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_charge', - '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': 'Start charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_charge', - 'unique_id': 'vf1aaaaa555777999_start_charge', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_stop_charge', - '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': 'Stop charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'stop_charge', - 'unique_id': 'vf1aaaaa555777999_stop_charge', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1zoe40vin_stop_charge', + 'unit_of_measurement': None, + }) # --- -# name: test_buttons[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start air conditioner', - }), - 'context': , - 'entity_id': 'button.reg_number_start_air_conditioner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_button_not_supported[zoe_40][button.reg_zoe_40_stop_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Stop charge', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start charge', - }), - 'context': , - 'entity_id': 'button.reg_number_start_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Stop charge', - }), - 'context': , - 'entity_id': 'button.reg_number_stop_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[captur_fuel][button.reg_captur_fuel_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_captur_fuel_start_air_conditioner', + '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': 'Start air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1capturfuelvin_start_air_conditioner', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[captur_fuel][button.reg_captur_fuel_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR-FUEL Start air conditioner', + }), + 'context': , + 'entity_id': 'button.reg_captur_fuel_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[captur_phev][button.reg_captur_phev_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_captur_phev_start_air_conditioner', + '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': 'Start air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1capturphevvin_start_air_conditioner', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[captur_phev][button.reg_captur_phev_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR_PHEV Start air conditioner', + }), + 'context': , + 'entity_id': 'button.reg_captur_phev_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[captur_phev][button.reg_captur_phev_start_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_captur_phev_start_charge', + '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': 'Start charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1capturphevvin_start_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[captur_phev][button.reg_captur_phev_start_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR_PHEV Start charge', + }), + 'context': , + 'entity_id': 'button.reg_captur_phev_start_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[captur_phev][button.reg_captur_phev_stop_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_captur_phev_stop_charge', + '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': 'Stop charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1capturphevvin_stop_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[captur_phev][button.reg_captur_phev_stop_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR_PHEV Stop charge', + }), + 'context': , + 'entity_id': 'button.reg_captur_phev_stop_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[twingo_3_electric][button.reg_twingo_iii_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_twingo_iii_start_air_conditioner', + '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': 'Start air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1twingoiiivin_start_air_conditioner', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[twingo_3_electric][button.reg_twingo_iii_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-TWINGO-III Start air conditioner', + }), + 'context': , + 'entity_id': 'button.reg_twingo_iii_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[twingo_3_electric][button.reg_twingo_iii_start_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_twingo_iii_start_charge', + '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': 'Start charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1twingoiiivin_start_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[twingo_3_electric][button.reg_twingo_iii_start_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-TWINGO-III Start charge', + }), + 'context': , + 'entity_id': 'button.reg_twingo_iii_start_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[twingo_3_electric][button.reg_twingo_iii_stop_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_twingo_iii_stop_charge', + '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': 'Stop charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1twingoiiivin_stop_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[twingo_3_electric][button.reg_twingo_iii_stop_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-TWINGO-III Stop charge', + }), + 'context': , + 'entity_id': 'button.reg_twingo_iii_stop_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[zoe_40][button.reg_zoe_40_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + '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': 'Start air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1zoe40vin_start_air_conditioner', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[zoe_40][button.reg_zoe_40_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start air conditioner', + }), + 'context': , + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[zoe_40][button.reg_zoe_40_start_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_charge', + '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': 'Start charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1zoe40vin_start_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[zoe_40][button.reg_zoe_40_start_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start charge', + }), + 'context': , + 'entity_id': 'button.reg_zoe_40_start_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[zoe_40][button.reg_zoe_40_stop_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_stop_charge', + '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': 'Stop charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1zoe40vin_stop_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[zoe_40][button.reg_zoe_40_stop_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Stop charge', + }), + 'context': , + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[zoe_50][button.reg_zoe_50_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_50_start_air_conditioner', + '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': 'Start air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1zoe50vin_start_air_conditioner', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[zoe_50][button.reg_zoe_50_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 Start air conditioner', + }), + 'context': , + 'entity_id': 'button.reg_zoe_50_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[zoe_50][button.reg_zoe_50_start_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_50_start_charge', + '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': 'Start charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1zoe50vin_start_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[zoe_50][button.reg_zoe_50_start_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 Start charge', + }), + 'context': , + 'entity_id': 'button.reg_zoe_50_start_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[zoe_50][button.reg_zoe_50_stop_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_50_stop_charge', + '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': 'Stop charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1zoe50vin_stop_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[zoe_50][button.reg_zoe_50_stop_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 Stop charge', + }), + 'context': , + 'entity_id': 'button.reg_zoe_50_stop_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- diff --git a/tests/components/renault/snapshots/test_device_tracker.ambr b/tests/components/renault/snapshots/test_device_tracker.ambr index 119defca4ac..7a35f70b51c 100644 --- a/tests/components/renault/snapshots/test_device_tracker.ambr +++ b/tests/components/renault/snapshots/test_device_tracker.ambr @@ -1,618 +1,300 @@ # serializer version: 1 -# name: test_device_tracker_empty[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_device_tracker_empty[zoe_50][device_tracker.reg_zoe_50_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_device_tracker_empty[captur_fuel].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'device_tracker', - 'entity_category': , - 'entity_id': 'device_tracker.reg_number_location', - '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': 'Location', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location', - 'unique_id': 'vf1aaaaa555777123_location', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_zoe_50_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_device_tracker_empty[captur_fuel].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Location', - 'source_type': , - }), - 'context': , - 'entity_id': 'device_tracker.reg_number_location', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Location', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1zoe50vin_location', + 'unit_of_measurement': None, + }) # --- -# name: test_device_tracker_empty[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_device_tracker_empty[zoe_50][device_tracker.reg_zoe_50_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 Location', + 'source_type': , }), - ]) + 'context': , + 'entity_id': 'device_tracker.reg_zoe_50_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_device_tracker_empty[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'device_tracker', - 'entity_category': , - 'entity_id': 'device_tracker.reg_number_location', - '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': 'Location', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location', - 'unique_id': 'vf1aaaaa555777123_location', - 'unit_of_measurement': None, +# name: test_device_tracker_errors[zoe_50][device_tracker.reg_zoe_50_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_device_tracker_empty[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Location', - 'source_type': , - }), - 'context': , - 'entity_id': 'device_tracker.reg_number_location', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_zoe_50_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_device_tracker_empty[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - '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': 'Location', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1zoe50vin_location', + 'unit_of_measurement': None, + }) # --- -# name: test_device_tracker_empty[zoe_40].1 - list([ - ]) -# --- -# name: test_device_tracker_empty[zoe_40].2 - list([ - ]) -# --- -# name: test_device_tracker_empty[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_device_tracker_errors[zoe_50][device_tracker.reg_zoe_50_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 Location', }), - ]) + 'context': , + 'entity_id': 'device_tracker.reg_zoe_50_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) # --- -# name: test_device_tracker_empty[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'device_tracker', - 'entity_category': , - 'entity_id': 'device_tracker.reg_number_location', - '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': 'Location', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location', - 'unique_id': 'vf1aaaaa555777999_location', - 'unit_of_measurement': None, +# name: test_device_trackers[captur_fuel][device_tracker.reg_captur_fuel_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_device_tracker_empty[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Location', - 'source_type': , - }), - 'context': , - 'entity_id': 'device_tracker.reg_number_location', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_captur_fuel_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_device_trackers[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - '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': 'Location', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1capturfuelvin_location', + 'unit_of_measurement': None, + }) # --- -# name: test_device_trackers[captur_fuel].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'device_tracker', - 'entity_category': , - 'entity_id': 'device_tracker.reg_number_location', - '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': 'Location', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location', - 'unique_id': 'vf1aaaaa555777123_location', - 'unit_of_measurement': None, +# name: test_device_trackers[captur_fuel][device_tracker.reg_captur_fuel_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR-FUEL Location', + 'gps_accuracy': 0, + 'latitude': 48.1234567, + 'longitude': 11.1234567, + 'source_type': , }), - ]) + 'context': , + 'entity_id': 'device_tracker.reg_captur_fuel_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) # --- -# name: test_device_trackers[captur_fuel].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Location', - 'gps_accuracy': 0, - 'latitude': 48.1234567, - 'longitude': 11.1234567, - 'source_type': , - }), - 'context': , - 'entity_id': 'device_tracker.reg_number_location', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'not_home', +# name: test_device_trackers[captur_phev][device_tracker.reg_captur_phev_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_device_trackers[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_captur_phev_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_device_trackers[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'device_tracker', - 'entity_category': , - 'entity_id': 'device_tracker.reg_number_location', - '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': 'Location', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location', - 'unique_id': 'vf1aaaaa555777123_location', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Location', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1capturphevvin_location', + 'unit_of_measurement': None, + }) # --- -# name: test_device_trackers[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Location', - 'gps_accuracy': 0, - 'latitude': 48.1234567, - 'longitude': 11.1234567, - 'source_type': , - }), - 'context': , - 'entity_id': 'device_tracker.reg_number_location', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'not_home', +# name: test_device_trackers[captur_phev][device_tracker.reg_captur_phev_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR_PHEV Location', + 'gps_accuracy': 0, + 'latitude': 48.1234567, + 'longitude': 11.1234567, + 'source_type': , }), - ]) + 'context': , + 'entity_id': 'device_tracker.reg_captur_phev_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) # --- -# name: test_device_trackers[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_device_trackers[twingo_3_electric][device_tracker.reg_twingo_iii_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_device_trackers[zoe_40].1 - list([ - ]) -# --- -# name: test_device_trackers[zoe_40].2 - list([ - ]) -# --- -# name: test_device_trackers[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_twingo_iii_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_device_trackers[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'device_tracker', - 'entity_category': , - 'entity_id': 'device_tracker.reg_number_location', - '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': 'Location', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location', - 'unique_id': 'vf1aaaaa555777999_location', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Location', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1twingoiiivin_location', + 'unit_of_measurement': None, + }) # --- -# name: test_device_trackers[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Location', - 'gps_accuracy': 0, - 'latitude': 48.1234567, - 'longitude': 11.1234567, - 'source_type': , - }), - 'context': , - 'entity_id': 'device_tracker.reg_number_location', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'not_home', +# name: test_device_trackers[twingo_3_electric][device_tracker.reg_twingo_iii_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-TWINGO-III Location', + 'gps_accuracy': 0, + 'latitude': 48.1234567, + 'longitude': 11.1234567, + 'source_type': , }), - ]) + 'context': , + 'entity_id': 'device_tracker.reg_twingo_iii_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- +# name: test_device_trackers[zoe_50][device_tracker.reg_zoe_50_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_zoe_50_location', + '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': 'Location', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1zoe50vin_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_trackers[zoe_50][device_tracker.reg_zoe_50_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 Location', + 'gps_accuracy': 0, + 'latitude': 48.1234567, + 'longitude': 11.1234567, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.reg_zoe_50_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) # --- diff --git a/tests/components/renault/snapshots/test_diagnostics.ambr b/tests/components/renault/snapshots/test_diagnostics.ambr index a2921dff35e..80ef412427d 100644 --- a/tests/components/renault/snapshots/test_diagnostics.ambr +++ b/tests/components/renault/snapshots/test_diagnostics.ambr @@ -24,8 +24,6 @@ 'externalTemperature': 8.0, 'hvacStatus': 'off', }), - 'res_state': dict({ - }), }), 'details': dict({ 'assets': list([ @@ -229,8 +227,6 @@ 'externalTemperature': 8.0, 'hvacStatus': 'off', }), - 'res_state': dict({ - }), }), 'details': dict({ 'assets': list([ diff --git a/tests/components/renault/snapshots/test_init.ambr b/tests/components/renault/snapshots/test_init.ambr new file mode 100644 index 00000000000..9a10083b227 --- /dev/null +++ b/tests/components/renault/snapshots/test_init.ambr @@ -0,0 +1,176 @@ +# serializer version: 1 +# name: test_device_registry[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1CAPTURFUELVIN', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'model_id': 'XJB1SU', + 'name': 'REG-CAPTUR-FUEL', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_registry[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1CAPTURPHEVVIN', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'model_id': 'XJB1SU', + 'name': 'REG-CAPTUR_PHEV', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_registry[twingo_3_electric] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1TWINGOIIIVIN', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Renault', + 'model': 'Twingo iii', + 'model_id': 'X071VE', + 'name': 'REG-TWINGO-III', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_registry[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1ZOE40VIN', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'model_id': 'X101VE', + 'name': 'REG-ZOE-40', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_registry[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1ZOE50VIN', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'model_id': 'X102VE', + 'name': 'REG-ZOE-50', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- diff --git a/tests/components/renault/snapshots/test_select.ambr b/tests/components/renault/snapshots/test_select.ambr index 526c8af5bc4..9df17d0a3ec 100644 --- a/tests/components/renault/snapshots/test_select.ambr +++ b/tests/components/renault/snapshots/test_select.ambr @@ -1,681 +1,361 @@ # serializer version: 1 -# name: test_select_empty[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_select_empty[zoe_40][select.reg_zoe_40_charge_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_select_empty[captur_fuel].1 - list([ - ]) -# --- -# name: test_select_empty[captur_fuel].2 - list([ - ]) -# --- -# name: test_select_empty[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), }), - ]) -# --- -# name: test_select_empty[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.reg_number_charge_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': 'Charge mode', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_mode', - 'unique_id': 'vf1aaaaa555777123_charge_mode', - 'unit_of_measurement': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_zoe_40_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_select_empty[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Charge mode', - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'context': , - 'entity_id': 'select.reg_number_charge_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge mode', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1zoe40vin_charge_mode', + 'unit_of_measurement': None, + }) # --- -# name: test_select_empty[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_select_empty[zoe_40][select.reg_zoe_40_charge_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Charge mode', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), }), - ]) + 'context': , + 'entity_id': 'select.reg_zoe_40_charge_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_select_empty[zoe_40].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.reg_number_charge_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': 'Charge mode', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_mode', - 'unique_id': 'vf1aaaaa555777999_charge_mode', - 'unit_of_measurement': None, +# name: test_select_errors[zoe_40][select.reg_zoe_40_charge_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_select_empty[zoe_40].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Charge mode', - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'context': , - 'entity_id': 'select.reg_number_charge_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), }), - ]) -# --- -# name: test_select_empty[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_zoe_40_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_select_empty[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.reg_number_charge_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': 'Charge mode', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_mode', - 'unique_id': 'vf1aaaaa555777999_charge_mode', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge mode', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1zoe40vin_charge_mode', + 'unit_of_measurement': None, + }) # --- -# name: test_select_empty[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Charge mode', - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'context': , - 'entity_id': 'select.reg_number_charge_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_select_errors[zoe_40][select.reg_zoe_40_charge_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Charge mode', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), }), - ]) + 'context': , + 'entity_id': 'select.reg_zoe_40_charge_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) # --- -# name: test_selects[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_selects[captur_phev][select.reg_captur_phev_charge_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_selects[captur_fuel].1 - list([ - ]) -# --- -# name: test_selects[captur_fuel].2 - list([ - ]) -# --- -# name: test_selects[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), }), - ]) -# --- -# name: test_selects[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.reg_number_charge_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': 'Charge mode', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_mode', - 'unique_id': 'vf1aaaaa555777123_charge_mode', - 'unit_of_measurement': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_captur_phev_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_selects[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Charge mode', - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'context': , - 'entity_id': 'select.reg_number_charge_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'always', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge mode', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1capturphevvin_charge_mode', + 'unit_of_measurement': None, + }) # --- -# name: test_selects[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_selects[captur_phev][select.reg_captur_phev_charge_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR_PHEV Charge mode', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), }), - ]) + 'context': , + 'entity_id': 'select.reg_captur_phev_charge_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'always', + }) # --- -# name: test_selects[zoe_40].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.reg_number_charge_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': 'Charge mode', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_mode', - 'unique_id': 'vf1aaaaa555777999_charge_mode', - 'unit_of_measurement': None, +# name: test_selects[twingo_3_electric][select.reg_twingo_iii_charge_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_selects[zoe_40].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Charge mode', - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'context': , - 'entity_id': 'select.reg_number_charge_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'always', + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), }), - ]) -# --- -# name: test_selects[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_twingo_iii_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_selects[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.reg_number_charge_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': 'Charge mode', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_mode', - 'unique_id': 'vf1aaaaa555777999_charge_mode', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge mode', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1twingoiiivin_charge_mode', + 'unit_of_measurement': None, + }) # --- -# name: test_selects[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Charge mode', - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'context': , - 'entity_id': 'select.reg_number_charge_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'schedule_mode', +# name: test_selects[twingo_3_electric][select.reg_twingo_iii_charge_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-TWINGO-III Charge mode', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), }), - ]) + 'context': , + 'entity_id': 'select.reg_twingo_iii_charge_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'always_charging', + }) +# --- +# name: test_selects[zoe_40][select.reg_zoe_40_charge_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_zoe_40_charge_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': 'Charge mode', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1zoe40vin_charge_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[zoe_40][select.reg_zoe_40_charge_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Charge mode', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), + }), + 'context': , + 'entity_id': 'select.reg_zoe_40_charge_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'always', + }) +# --- +# name: test_selects[zoe_50][select.reg_zoe_50_charge_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_zoe_50_charge_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': 'Charge mode', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1zoe50vin_charge_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[zoe_50][select.reg_zoe_50_charge_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 Charge mode', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), + }), + 'context': , + 'entity_id': 'select.reg_zoe_50_charge_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'schedule_mode', + }) # --- diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index 175ad2422ed..b6c9569e0d3 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -1,5345 +1,4645 @@ # serializer version: 1 -# name: test_sensor_empty[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_battery_level', + 'unit_of_measurement': '%', + }) # --- -# name: test_sensor_empty[captur_fuel].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_mileage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mileage', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1aaaaa555777123_mileage', +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-ZOE-40 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_battery_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_autonomy', + '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 autonomy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1zoe40vin_battery_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_battery_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-ZOE-40 Battery autonomy', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_fuel_autonomy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fuel autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fuel_autonomy', - 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_battery_available_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_available_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': 'Battery available energy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1zoe40vin_battery_available_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_battery_available_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-ZOE-40 Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_available_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_battery_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_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': 'Battery temperature', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1zoe40vin_battery_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_battery_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-ZOE-40 Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_charge_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charge_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': 'Charge state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1zoe40vin_charge_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_charge_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-ZOE-40 Charge state', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charge_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_charging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charging_power', + '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 power', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'vf1zoe40vin_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-ZOE-40 Charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_charging_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1zoe40vin_charging_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_charging_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'REG-ZOE-40 Charging remaining time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charging_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_hvac_soc_threshold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_hvac_soc_threshold', + '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': 'HVAC SoC threshold', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1zoe40vin_hvac_soc_threshold', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_hvac_soc_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 HVAC SoC threshold', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_hvac_soc_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_last_battery_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_last_battery_activity', + '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 battery activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1zoe40vin_battery_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_last_battery_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-40 Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_last_battery_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_last_hvac_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_last_hvac_activity', + '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 HVAC activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1zoe40vin_hvac_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_last_hvac_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-40 Last HVAC activity', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_last_hvac_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1zoe40vin_mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-ZOE-40 Mileage', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_fuel_quantity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fuel quantity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fuel_quantity', - 'unique_id': 'vf1aaaaa555777123_fuel_quantity', + 'context': , + 'entity_id': 'sensor.reg_zoe_40_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1zoe40vin_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-ZOE-40 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_plug_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_plug_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': 'Plug state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1zoe40vin_plug_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_plug_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-ZOE-40 Plug state', + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_plug_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-ZOE-40 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_battery_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_autonomy', + '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 autonomy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1zoe40vin_battery_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_battery_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-ZOE-40 Battery autonomy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_battery_available_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_available_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': 'Battery available energy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1zoe40vin_battery_available_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_battery_available_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-ZOE-40 Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_available_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_battery_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_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': 'Battery temperature', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1zoe40vin_battery_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_battery_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-ZOE-40 Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_charge_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charge_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': 'Charge state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1zoe40vin_charge_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_charge_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-ZOE-40 Charge state', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charge_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_charging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charging_power', + '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 power', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'vf1zoe40vin_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-ZOE-40 Charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_charging_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1zoe40vin_charging_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_charging_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'REG-ZOE-40 Charging remaining time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charging_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_hvac_soc_threshold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_hvac_soc_threshold', + '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': 'HVAC SoC threshold', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1zoe40vin_hvac_soc_threshold', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_hvac_soc_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 HVAC SoC threshold', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_hvac_soc_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_last_battery_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_last_battery_activity', + '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 battery activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1zoe40vin_battery_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_last_battery_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-40 Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_last_battery_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_last_hvac_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_last_hvac_activity', + '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 HVAC activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1zoe40vin_hvac_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_last_hvac_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-40 Last HVAC activity', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_last_hvac_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1zoe40vin_mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-ZOE-40 Mileage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1zoe40vin_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-ZOE-40 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_plug_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_plug_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': 'Plug state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1zoe40vin_plug_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_plug_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-ZOE-40 Plug state', + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_plug_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_fuel_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_fuel_fuel_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel autonomy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_autonomy', + 'unique_id': 'vf1capturfuelvin_fuel_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_fuel_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-CAPTUR-FUEL Fuel autonomy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_captur_fuel_fuel_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_fuel_quantity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_fuel_fuel_quantity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel quantity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_quantity', + 'unique_id': 'vf1capturfuelvin_fuel_quantity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_fuel_quantity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'REG-CAPTUR-FUEL Fuel quantity', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_location_activity', - '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 location activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location_last_activity', - 'unique_id': 'vf1aaaaa555777123_location_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start', - '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': 'Remote engine start', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1aaaaa555777123_res_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - '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': 'Remote engine start code', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1aaaaa555777123_res_state_code', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'sensor.reg_captur_fuel_fuel_quantity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) # --- -# name: test_sensor_empty[captur_fuel].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_last_location_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Fuel autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_fuel_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_fuel_last_location_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'volume', - 'friendly_name': 'REG-NUMBER Fuel quantity', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_fuel_quantity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last location activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_location_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start code', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last location activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1capturfuelvin_location_last_activity', + 'unit_of_measurement': None, + }) # --- -# name: test_sensor_empty[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_last_location_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-CAPTUR-FUEL Last location activity', }), - ]) + 'context': , + 'entity_id': 'sensor.reg_captur_fuel_last_location_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-02-18T16:58:38+00:00', + }) # --- -# name: test_sensor_empty[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_battery_level', - 'unit_of_measurement': '%', +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charge_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': 'Charge state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state', - 'unique_id': 'vf1aaaaa555777123_charge_state', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Charging remaining time', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charging_remaining_time', - 'unique_id': 'vf1aaaaa555777123_charging_remaining_time', - 'unit_of_measurement': , + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_fuel_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_admissible_charging_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Admissible charging power', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'admissible_charging_power', - 'unique_id': 'vf1aaaaa555777123_charging_power', + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1capturfuelvin_mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-CAPTUR-FUEL Mileage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_captur_fuel_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5567', + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_remote_engine_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_fuel_remote_engine_start', + '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': 'Remote engine start', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1capturfuelvin_res_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_remote_engine_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR-FUEL Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_captur_fuel_remote_engine_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Stopped, ready for RES', + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_remote_engine_start_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_fuel_remote_engine_start_code', + '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': 'Remote engine start code', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1capturfuelvin_res_state_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_remote_engine_start_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR-FUEL Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_captur_fuel_remote_engine_start_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_admissible_charging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_admissible_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Admissible charging power', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'admissible_charging_power', + 'unique_id': 'vf1capturphevvin_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_admissible_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-CAPTUR_PHEV Admissible charging power', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_plug_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': 'Plug state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'plug_state', - 'unique_id': 'vf1aaaaa555777123_plug_state', - 'unit_of_measurement': None, + 'context': , + 'entity_id': 'sensor.reg_captur_phev_admissible_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.0', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_autonomy', - '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 autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_autonomy', - 'unique_id': 'vf1aaaaa555777123_battery_autonomy', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1capturphevvin_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-CAPTUR_PHEV Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_captur_phev_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_battery_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_battery_autonomy', + '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 autonomy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1capturphevvin_battery_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_battery_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-CAPTUR_PHEV Battery autonomy', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_available_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': 'Battery available energy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_available_energy', - 'unique_id': 'vf1aaaaa555777123_battery_available_energy', + 'context': , + 'entity_id': 'sensor.reg_captur_phev_battery_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '141', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_battery_available_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_battery_available_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': 'Battery available energy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1capturphevvin_battery_available_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_battery_available_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-CAPTUR_PHEV Battery available energy', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_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': 'Battery temperature', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_temperature', - 'unique_id': 'vf1aaaaa555777123_battery_temperature', + 'context': , + 'entity_id': 'sensor.reg_captur_phev_battery_available_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_battery_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_battery_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': 'Battery temperature', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1capturphevvin_battery_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_battery_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-CAPTUR_PHEV Battery temperature', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_battery_activity', - '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 battery activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_last_activity', - 'unique_id': 'vf1aaaaa555777123_battery_last_activity', - 'unit_of_measurement': None, + 'context': , + 'entity_id': 'sensor.reg_captur_phev_battery_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_charge_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_mileage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mileage', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1aaaaa555777123_mileage', + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_charge_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': 'Charge state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1capturphevvin_charge_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_charge_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-CAPTUR_PHEV Charge state', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_captur_phev_charge_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charge_in_progress', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_charging_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1capturphevvin_charging_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_charging_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'REG-CAPTUR_PHEV Charging remaining time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_captur_phev_charging_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '145', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_fuel_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_fuel_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel autonomy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_autonomy', + 'unique_id': 'vf1capturphevvin_fuel_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_fuel_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-CAPTUR_PHEV Fuel autonomy', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_fuel_autonomy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fuel autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fuel_autonomy', - 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', - 'unit_of_measurement': , + 'context': , + 'entity_id': 'sensor.reg_captur_phev_fuel_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_fuel_quantity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_fuel_quantity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fuel quantity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fuel_quantity', - 'unique_id': 'vf1aaaaa555777123_fuel_quantity', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_fuel_quantity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel quantity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_quantity', + 'unique_id': 'vf1capturphevvin_fuel_quantity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_fuel_quantity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'REG-CAPTUR_PHEV Fuel quantity', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_location_activity', - '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 location activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location_last_activity', - 'unique_id': 'vf1aaaaa555777123_location_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start', - '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': 'Remote engine start', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1aaaaa555777123_res_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - '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': 'Remote engine start code', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1aaaaa555777123_res_state_code', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'sensor.reg_captur_phev_fuel_quantity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) # --- -# name: test_sensor_empty[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_sensors[captur_phev][sensor.reg_captur_phev_last_battery_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Charge state', - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_charge_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_last_battery_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'REG-NUMBER Charging remaining time', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'REG-NUMBER Admissible charging power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_admissible_charging_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Plug state', - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_plug_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Battery autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'REG-NUMBER Battery available energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_available_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Battery temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last battery activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_battery_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Fuel autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_fuel_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'volume', - 'friendly_name': 'REG-NUMBER Fuel quantity', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_fuel_quantity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last location activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_location_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start code', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last battery activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1capturphevvin_battery_last_activity', + 'unit_of_measurement': None, + }) # --- -# name: test_sensor_empty[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[captur_phev][sensor.reg_captur_phev_last_battery_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-CAPTUR_PHEV Last battery activity', }), - ]) + 'context': , + 'entity_id': 'sensor.reg_captur_phev_last_battery_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-01-12T21:40:16+00:00', + }) # --- -# name: test_sensor_empty[zoe_40].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_battery_level', - 'unit_of_measurement': '%', +# name: test_sensors[captur_phev][sensor.reg_captur_phev_last_location_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charge_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': 'Charge state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state', - 'unique_id': 'vf1aaaaa555777999_charge_state', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_last_location_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Charging remaining time', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charging_remaining_time', - 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', - 'unit_of_measurement': , + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charging_power', - '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 power', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charging_power', - 'unique_id': 'vf1aaaaa555777999_charging_power', + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last location activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1capturphevvin_location_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_last_location_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-CAPTUR_PHEV Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_captur_phev_last_location_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-02-18T16:58:38+00:00', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1capturphevvin_mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-CAPTUR_PHEV Mileage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_captur_phev_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5567', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_plug_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_plug_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': 'Plug state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1capturphevvin_plug_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_plug_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-CAPTUR_PHEV Plug state', + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_captur_phev_plug_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plugged', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_remote_engine_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_remote_engine_start', + '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': 'Remote engine start', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1capturphevvin_res_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_remote_engine_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR_PHEV Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_captur_phev_remote_engine_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Stopped, ready for RES', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_remote_engine_start_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_remote_engine_start_code', + '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': 'Remote engine start code', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1capturphevvin_res_state_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_remote_engine_start_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR_PHEV Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_captur_phev_remote_engine_start_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_admissible_charging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_admissible_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Admissible charging power', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'admissible_charging_power', + 'unique_id': 'vf1twingoiiivin_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_admissible_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-TWINGO-III Admissible charging power', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_plug_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': 'Plug state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'plug_state', - 'unique_id': 'vf1aaaaa555777999_plug_state', - 'unit_of_measurement': None, + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_admissible_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_autonomy', - '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 autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_autonomy', - 'unique_id': 'vf1aaaaa555777999_battery_autonomy', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1twingoiiivin_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-TWINGO-III Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '96', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_battery_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_battery_autonomy', + '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 autonomy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1twingoiiivin_battery_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_battery_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-TWINGO-III Battery autonomy', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_available_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': 'Battery available energy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_available_energy', - 'unique_id': 'vf1aaaaa555777999_battery_available_energy', + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_battery_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '182', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_battery_available_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_battery_available_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': 'Battery available energy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1twingoiiivin_battery_available_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_battery_available_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-TWINGO-III Battery available energy', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_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': 'Battery temperature', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_temperature', - 'unique_id': 'vf1aaaaa555777999_battery_temperature', + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_battery_available_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_battery_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_battery_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': 'Battery temperature', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1twingoiiivin_battery_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_battery_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-TWINGO-III Battery temperature', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_battery_activity', - '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 battery activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_last_activity', - 'unique_id': 'vf1aaaaa555777999_battery_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_mileage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mileage', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1aaaaa555777999_mileage', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'outside_temperature', - 'unique_id': 'vf1aaaaa555777999_outside_temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_hvac_soc_threshold', - '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': 'HVAC SoC threshold', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_soc_threshold', - 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', - 'unit_of_measurement': '%', - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_hvac_activity', - '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 HVAC activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_last_activity', - 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start', - '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': 'Remote engine start', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1aaaaa555777999_res_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - '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': 'Remote engine start code', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1aaaaa555777999_res_state_code', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_battery_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_sensor_empty[zoe_40].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_charge_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Charge state', - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_charge_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'REG-NUMBER Charging remaining time', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_charge_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'REG-NUMBER Charging power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_charging_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Plug state', - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_plug_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Battery autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'REG-NUMBER Battery available energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_available_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Battery temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last battery activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_battery_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Outside temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_outside_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER HVAC SoC threshold', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_hvac_soc_threshold', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last HVAC activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_hvac_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start code', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1twingoiiivin_charge_state', + 'unit_of_measurement': None, + }) # --- -# name: test_sensor_empty[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_charge_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-TWINGO-III Charge state', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), }), - ]) + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_charge_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'waiting_for_current_charge', + }) # --- -# name: test_sensor_empty[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_battery_level', - 'unit_of_measurement': '%', +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_charging_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charge_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': 'Charge state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state', - 'unique_id': 'vf1aaaaa555777999_charge_state', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Charging remaining time', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charging_remaining_time', - 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1twingoiiivin_charging_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_charging_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'REG-TWINGO-III Charging remaining time', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_admissible_charging_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Admissible charging power', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'admissible_charging_power', - 'unique_id': 'vf1aaaaa555777999_charging_power', - 'unit_of_measurement': , + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_charging_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_hvac_soc_threshold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_plug_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': 'Plug state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'plug_state', - 'unique_id': 'vf1aaaaa555777999_plug_state', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_hvac_soc_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_autonomy', - '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 autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_autonomy', - 'unique_id': 'vf1aaaaa555777999_battery_autonomy', + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1twingoiiivin_hvac_soc_threshold', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_hvac_soc_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-TWINGO-III HVAC SoC threshold', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_hvac_soc_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_last_battery_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_last_battery_activity', + '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 battery activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1twingoiiivin_battery_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_last_battery_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-TWINGO-III Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_last_battery_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-28T05:27:07+00:00', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_last_hvac_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_last_hvac_activity', + '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 HVAC activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1twingoiiivin_hvac_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_last_hvac_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-TWINGO-III Last HVAC activity', + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_last_hvac_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-28T04:29:26+00:00', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_last_location_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_last_location_activity', + '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 location activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1twingoiiivin_location_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_last_location_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-TWINGO-III Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_last_location_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-02-18T16:58:38+00:00', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1twingoiiivin_mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-TWINGO-III Mileage', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_available_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': 'Battery available energy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_available_energy', - 'unique_id': 'vf1aaaaa555777999_battery_available_energy', + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49114', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1twingoiiivin_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-TWINGO-III Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_plug_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_plug_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': 'Plug state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1twingoiiivin_plug_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_plug_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-TWINGO-III Plug state', + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_plug_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-ZOE-40 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_battery_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_autonomy', + '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 autonomy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1zoe40vin_battery_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_battery_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-ZOE-40 Battery autonomy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '141', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_battery_available_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_available_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': 'Battery available energy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1zoe40vin_battery_available_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_battery_available_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-ZOE-40 Battery available energy', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_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': 'Battery temperature', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_temperature', - 'unique_id': 'vf1aaaaa555777999_battery_temperature', + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_available_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_battery_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_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': 'Battery temperature', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1zoe40vin_battery_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_battery_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-ZOE-40 Battery temperature', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_battery_activity', - '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 battery activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_last_activity', - 'unique_id': 'vf1aaaaa555777999_battery_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_mileage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mileage', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1aaaaa555777999_mileage', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'outside_temperature', - 'unique_id': 'vf1aaaaa555777999_outside_temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_hvac_soc_threshold', - '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': 'HVAC SoC threshold', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_soc_threshold', - 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', - 'unit_of_measurement': '%', - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_hvac_activity', - '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 HVAC activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_last_activity', - 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_location_activity', - '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 location activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location_last_activity', - 'unique_id': 'vf1aaaaa555777999_location_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start', - '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': 'Remote engine start', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1aaaaa555777999_res_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - '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': 'Remote engine start code', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1aaaaa555777999_res_state_code', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) # --- -# name: test_sensor_empty[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_sensors[zoe_40][sensor.reg_zoe_40_charge_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Charge state', - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_charge_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'REG-NUMBER Charging remaining time', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charge_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'REG-NUMBER Admissible charging power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_admissible_charging_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Plug state', - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_plug_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Battery autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'REG-NUMBER Battery available energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_available_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Battery temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last battery activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_battery_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Outside temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_outside_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER HVAC SoC threshold', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_hvac_soc_threshold', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last HVAC activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_hvac_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last location activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_location_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start code', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1zoe40vin_charge_state', + 'unit_of_measurement': None, + }) # --- -# name: test_sensors[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[zoe_40][sensor.reg_zoe_40_charge_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-ZOE-40 Charge state', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), }), - ]) + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charge_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charge_in_progress', + }) # --- -# name: test_sensors[captur_fuel].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_mileage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mileage', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1aaaaa555777123_mileage', - 'unit_of_measurement': , +# name: test_sensors[zoe_40][sensor.reg_zoe_40_charging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_fuel_autonomy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fuel autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fuel_autonomy', - 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', - 'unit_of_measurement': , + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_fuel_quantity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fuel quantity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fuel_quantity', - 'unique_id': 'vf1aaaaa555777123_fuel_quantity', - 'unit_of_measurement': , + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_location_activity', - '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 location activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location_last_activity', - 'unique_id': 'vf1aaaaa555777123_location_last_activity', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start', - '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': 'Remote engine start', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1aaaaa555777123_res_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - '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': 'Remote engine start code', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1aaaaa555777123_res_state_code', - 'unit_of_measurement': None, - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'vf1zoe40vin_charging_power', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[captur_fuel].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '5567', +# name: test_sensors[zoe_40][sensor.reg_zoe_40_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-ZOE-40 Charging power', + 'state_class': , + 'unit_of_measurement': , }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Fuel autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_fuel_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '35', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'volume', - 'friendly_name': 'REG-NUMBER Fuel quantity', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_fuel_quantity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last location activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_location_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2020-02-18T16:58:38+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Stopped, ready for RES', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start code', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '10', - }), - ]) + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.027', + }) # --- -# name: test_sensors[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[zoe_40][sensor.reg_zoe_40_charging_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1zoe40vin_charging_remaining_time', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_battery_level', - 'unit_of_measurement': '%', - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charge_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': 'Charge state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state', - 'unique_id': 'vf1aaaaa555777123_charge_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Charging remaining time', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charging_remaining_time', - 'unique_id': 'vf1aaaaa555777123_charging_remaining_time', +# name: test_sensors[zoe_40][sensor.reg_zoe_40_charging_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'REG-ZOE-40 Charging remaining time', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_admissible_charging_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Admissible charging power', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'admissible_charging_power', - 'unique_id': 'vf1aaaaa555777123_charging_power', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_plug_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': 'Plug state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'plug_state', - 'unique_id': 'vf1aaaaa555777123_plug_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_autonomy', - '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 autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_autonomy', - 'unique_id': 'vf1aaaaa555777123_battery_autonomy', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_available_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': 'Battery available energy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_available_energy', - 'unique_id': 'vf1aaaaa555777123_battery_available_energy', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_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': 'Battery temperature', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_temperature', - 'unique_id': 'vf1aaaaa555777123_battery_temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_battery_activity', - '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 battery activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_last_activity', - 'unique_id': 'vf1aaaaa555777123_battery_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_mileage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mileage', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1aaaaa555777123_mileage', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_fuel_autonomy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fuel autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fuel_autonomy', - 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_fuel_quantity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fuel quantity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fuel_quantity', - 'unique_id': 'vf1aaaaa555777123_fuel_quantity', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_location_activity', - '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 location activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location_last_activity', - 'unique_id': 'vf1aaaaa555777123_location_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start', - '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': 'Remote engine start', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1aaaaa555777123_res_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - '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': 'Remote engine start code', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1aaaaa555777123_res_state_code', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charging_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '145', + }) # --- -# name: test_sensors[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '60', +# name: test_sensors[zoe_40][sensor.reg_zoe_40_hvac_soc_threshold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Charge state', - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_charge_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'charge_in_progress', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_hvac_soc_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'REG-NUMBER Charging remaining time', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '145', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'REG-NUMBER Admissible charging power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_admissible_charging_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '27.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Plug state', - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_plug_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'plugged', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Battery autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '141', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'REG-NUMBER Battery available energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_available_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '31', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Battery temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '20', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last battery activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_battery_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2020-01-12T21:40:16+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '5567', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Fuel autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_fuel_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '35', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'volume', - 'friendly_name': 'REG-NUMBER Fuel quantity', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_fuel_quantity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last location activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_location_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2020-02-18T16:58:38+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Stopped, ready for RES', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start code', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '10', - }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1zoe40vin_hvac_soc_threshold', + 'unit_of_measurement': '%', + }) # --- -# name: test_sensors[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[zoe_40].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_battery_level', +# name: test_sensors[zoe_40][sensor.reg_zoe_40_hvac_soc_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 HVAC SoC threshold', 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charge_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': 'Charge state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state', - 'unique_id': 'vf1aaaaa555777999_charge_state', - 'unit_of_measurement': None, + 'context': , + 'entity_id': 'sensor.reg_zoe_40_hvac_soc_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_last_battery_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Charging remaining time', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charging_remaining_time', - 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_last_battery_activity', + '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 battery activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1zoe40vin_battery_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_last_battery_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-40 Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_last_battery_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-01-12T21:40:16+00:00', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_last_hvac_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_last_hvac_activity', + '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 HVAC activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1zoe40vin_hvac_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_last_hvac_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-40 Last HVAC activity', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_last_hvac_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1zoe40vin_mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-ZOE-40 Mileage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49114', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1zoe40vin_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-ZOE-40 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_plug_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_plug_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': 'Plug state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1zoe40vin_plug_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_plug_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-ZOE-40 Plug state', + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_plug_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plugged', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_admissible_charging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_admissible_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Admissible charging power', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'admissible_charging_power', + 'unique_id': 'vf1zoe50vin_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_admissible_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-ZOE-50 Admissible charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_admissible_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe50vin_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-ZOE-50 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_battery_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_battery_autonomy', + '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 autonomy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1zoe50vin_battery_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_battery_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-ZOE-50 Battery autonomy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_battery_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_battery_available_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_battery_available_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': 'Battery available energy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1zoe50vin_battery_available_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_battery_available_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-ZOE-50 Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_battery_available_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_battery_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_battery_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': 'Battery temperature', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1zoe50vin_battery_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_battery_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-ZOE-50 Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_battery_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_charge_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_charge_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': 'Charge state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1zoe50vin_charge_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_charge_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-ZOE-50 Charge state', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_charge_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charge_error', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_charging_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1zoe50vin_charging_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_charging_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'REG-ZOE-50 Charging remaining time', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charging_power', - '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 power', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charging_power', - 'unique_id': 'vf1aaaaa555777999_charging_power', - 'unit_of_measurement': , + 'context': , + 'entity_id': 'sensor.reg_zoe_50_charging_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_hvac_soc_threshold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_plug_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': 'Plug state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'plug_state', - 'unique_id': 'vf1aaaaa555777999_plug_state', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_hvac_soc_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_autonomy', - '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 autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_autonomy', - 'unique_id': 'vf1aaaaa555777999_battery_autonomy', - 'unit_of_measurement': , + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_available_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': 'Battery available energy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_available_energy', - 'unique_id': 'vf1aaaaa555777999_battery_available_energy', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_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': 'Battery temperature', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_temperature', - 'unique_id': 'vf1aaaaa555777999_battery_temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_battery_activity', - '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 battery activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_last_activity', - 'unique_id': 'vf1aaaaa555777999_battery_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_mileage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mileage', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1aaaaa555777999_mileage', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'outside_temperature', - 'unique_id': 'vf1aaaaa555777999_outside_temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_hvac_soc_threshold', - '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': 'HVAC SoC threshold', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_soc_threshold', - 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1zoe50vin_hvac_soc_threshold', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_hvac_soc_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 HVAC SoC threshold', 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_hvac_activity', - '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 HVAC activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_last_activity', - 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start', - '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': 'Remote engine start', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1aaaaa555777999_res_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - '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': 'Remote engine start code', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1aaaaa555777999_res_state_code', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'sensor.reg_zoe_50_hvac_soc_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) # --- -# name: test_sensors[zoe_40].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '60', +# name: test_sensors[zoe_50][sensor.reg_zoe_50_last_battery_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Charge state', - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_charge_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'charge_in_progress', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_last_battery_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'REG-NUMBER Charging remaining time', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '145', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'REG-NUMBER Charging power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_charging_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.027', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Plug state', - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_plug_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'plugged', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Battery autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '141', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'REG-NUMBER Battery available energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_available_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '31', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Battery temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '20', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last battery activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_battery_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2020-01-12T21:40:16+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '49114', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Outside temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_outside_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '8.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER HVAC SoC threshold', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_hvac_soc_threshold', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last HVAC activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_hvac_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start code', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last battery activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1zoe50vin_battery_last_activity', + 'unit_of_measurement': None, + }) # --- -# name: test_sensors[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[zoe_50][sensor.reg_zoe_50_last_battery_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-50 Last battery activity', }), - ]) + 'context': , + 'entity_id': 'sensor.reg_zoe_50_last_battery_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-11-17T08:06:48+00:00', + }) # --- -# name: test_sensors[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_battery_level', - 'unit_of_measurement': '%', +# name: test_sensors[zoe_50][sensor.reg_zoe_50_last_hvac_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charge_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': 'Charge state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state', - 'unique_id': 'vf1aaaaa555777999_charge_state', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_last_hvac_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Charging remaining time', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charging_remaining_time', - 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', - 'unit_of_measurement': , + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_admissible_charging_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Admissible charging power', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'admissible_charging_power', - 'unique_id': 'vf1aaaaa555777999_charging_power', - 'unit_of_measurement': , + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last HVAC activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1zoe50vin_hvac_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_last_hvac_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-50 Last HVAC activity', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_plug_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': 'Plug state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'plug_state', - 'unique_id': 'vf1aaaaa555777999_plug_state', - 'unit_of_measurement': None, + 'context': , + 'entity_id': 'sensor.reg_zoe_50_last_hvac_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-12-03T00:00:00+00:00', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_last_location_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_autonomy', - '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 autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_autonomy', - 'unique_id': 'vf1aaaaa555777999_battery_autonomy', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_last_location_activity', + '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 location activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1zoe50vin_location_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_last_location_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-50 Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_last_location_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-02-18T16:58:38+00:00', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1zoe50vin_mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-ZOE-50 Mileage', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_available_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': 'Battery available energy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_available_energy', - 'unique_id': 'vf1aaaaa555777999_battery_available_energy', - 'unit_of_measurement': , + 'context': , + 'entity_id': 'sensor.reg_zoe_50_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49114', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_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': 'Battery temperature', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_temperature', - 'unique_id': 'vf1aaaaa555777999_battery_temperature', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1zoe50vin_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-ZOE-50 Outside temperature', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_battery_activity', - '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 battery activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_last_activity', - 'unique_id': 'vf1aaaaa555777999_battery_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_mileage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mileage', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1aaaaa555777999_mileage', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'outside_temperature', - 'unique_id': 'vf1aaaaa555777999_outside_temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_hvac_soc_threshold', - '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': 'HVAC SoC threshold', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_soc_threshold', - 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', - 'unit_of_measurement': '%', - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_hvac_activity', - '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 HVAC activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_last_activity', - 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_location_activity', - '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 location activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location_last_activity', - 'unique_id': 'vf1aaaaa555777999_location_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start', - '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': 'Remote engine start', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1aaaaa555777999_res_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - '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': 'Remote engine start code', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1aaaaa555777999_res_state_code', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'sensor.reg_zoe_50_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_sensors[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '50', +# name: test_sensors[zoe_50][sensor.reg_zoe_50_plug_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Charge state', - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_charge_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'charge_error', + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'REG-NUMBER Charging remaining time', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_plug_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'REG-NUMBER Admissible charging power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_admissible_charging_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Plug state', - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_plug_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unplugged', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Battery autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '128', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'REG-NUMBER Battery available energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_available_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Battery temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last battery activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_battery_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2020-11-17T08:06:48+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '49114', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Outside temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_outside_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER HVAC SoC threshold', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_hvac_soc_threshold', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '30.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last HVAC activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_hvac_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2020-12-03T00:00:00+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last location activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_location_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2020-02-18T16:58:38+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Stopped, ready for RES', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start code', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '10', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1zoe50vin_plug_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_plug_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-ZOE-50 Plug state', + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_plug_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unplugged', + }) # --- diff --git a/tests/components/renault/test_binary_sensor.py b/tests/components/renault/test_binary_sensor.py index 52b6de33f14..1a7863780b1 100644 --- a/tests/components/renault/test_binary_sensor.py +++ b/tests/components/renault/test_binary_sensor.py @@ -9,10 +9,9 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry 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 check_device_registry, check_entities_unavailable -from .const import MOCK_VEHICLES +from tests.common import snapshot_platform pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -28,7 +27,6 @@ def override_platforms() -> Generator[None]: async def test_binary_sensors( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -36,28 +34,14 @@ async def test_binary_sensors( 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 - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_no_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_binary_sensor_empty( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -65,42 +49,22 @@ async def test_binary_sensor_empty( 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 - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_binary_sensor_errors( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault binary sensors with temporary failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - - expected_entities = mock_vehicle[Platform.BINARY_SENSOR] - assert len(entity_registry.entities) == len(expected_entities) - - check_entities_unavailable(hass, entity_registry, expected_entities) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_access_denied_exception") @@ -108,17 +72,12 @@ async def test_binary_sensor_errors( async def test_binary_sensor_access_denied( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for Renault binary sensors with access denied failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - assert len(entity_registry.entities) == 0 @@ -127,15 +86,10 @@ async def test_binary_sensor_access_denied( async def test_binary_sensor_not_supported( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for Renault binary sensors with not supported failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - assert len(entity_registry.entities) == 0 diff --git a/tests/components/renault/test_button.py b/tests/components/renault/test_button.py index 32c5ce651ae..61754578948 100644 --- a/tests/components/renault/test_button.py +++ b/tests/components/renault/test_button.py @@ -9,14 +9,11 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er -from . import check_device_registry, check_entities_no_data -from .const import ATTR_ENTITY_ID, MOCK_VEHICLES - -from tests.common import load_fixture +from tests.common import load_fixture, snapshot_platform pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -32,7 +29,6 @@ def override_platforms() -> Generator[None]: async def test_buttons( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -40,28 +36,14 @@ async def test_buttons( 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 - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_no_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_button_empty( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -69,42 +51,22 @@ async def test_button_empty( 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 - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_button_errors( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault device trackers with temporary failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - - expected_entities = mock_vehicle[Platform.BUTTON] - assert len(entity_registry.entities) == len(expected_entities) - - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_access_denied_exception") @@ -112,21 +74,14 @@ async def test_button_errors( async def test_button_access_denied( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault device trackers with access denied failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - - expected_entities = mock_vehicle[Platform.BUTTON] - assert len(entity_registry.entities) == len(expected_entities) - - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_not_supported_exception") @@ -134,21 +89,14 @@ async def test_button_access_denied( async def test_button_not_supported( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault device trackers with not supported failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - - expected_entities = mock_vehicle[Platform.BUTTON] - assert len(entity_registry.entities) == len(expected_entities) - - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_data") @@ -161,7 +109,7 @@ async def test_button_start_charge( await hass.async_block_till_done() data = { - ATTR_ENTITY_ID: "button.reg_number_start_charge", + ATTR_ENTITY_ID: "button.reg_zoe_40_start_charge", } with patch( @@ -189,7 +137,7 @@ async def test_button_stop_charge( await hass.async_block_till_done() data = { - ATTR_ENTITY_ID: "button.reg_number_stop_charge", + ATTR_ENTITY_ID: "button.reg_zoe_40_stop_charge", } with patch( @@ -217,7 +165,7 @@ async def test_button_start_air_conditioner( await hass.async_block_till_done() data = { - ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", + ATTR_ENTITY_ID: "button.reg_zoe_40_start_air_conditioner", } with patch( diff --git a/tests/components/renault/test_config_flow.py b/tests/components/renault/test_config_flow.py index 781b7efe226..9a7146c96cd 100644 --- a/tests/components/renault/test_config_flow.py +++ b/tests/components/renault/test_config_flow.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import aiohttp_client -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, get_schema_suggested_value, load_fixture pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -67,6 +67,11 @@ async def test_config_flow_single_account( assert result["step_id"] == "user" assert result["errors"] == {"base": error} + data_schema = result["data_schema"].schema + assert get_schema_suggested_value(data_schema, CONF_LOCALE) == "fr_FR" + assert get_schema_suggested_value(data_schema, CONF_USERNAME) == "email@test.com" + assert get_schema_suggested_value(data_schema, CONF_PASSWORD) == "test" + renault_account = AsyncMock() type(renault_account).account_id = PropertyMock(return_value="account_id_1") renault_account.get_vehicles.return_value = ( @@ -278,3 +283,114 @@ async def test_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> Non assert config_entry.data[CONF_USERNAME] == "email@test.com" assert config_entry.data[CONF_PASSWORD] == "any" + + +async def test_reconfigure( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure works.""" + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + data_schema = result["data_schema"].schema + assert get_schema_suggested_value(data_schema, CONF_LOCALE) == "fr_FR" + assert get_schema_suggested_value(data_schema, CONF_USERNAME) == "email@test.com" + assert get_schema_suggested_value(data_schema, CONF_PASSWORD) == "test" + + renault_account = AsyncMock() + type(renault_account).account_id = PropertyMock(return_value="account_id_1") + renault_account.get_vehicles.return_value = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture("renault/vehicle_zoe_40.json") + ) + ) + + # Account list single + with ( + patch("renault_api.renault_session.RenaultSession.login"), + patch( + "renault_api.renault_account.RenaultAccount.account_id", return_value="123" + ), + patch( + "renault_api.renault_client.RenaultClient.get_api_accounts", + return_value=[renault_account], + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email2@test.com", + CONF_PASSWORD: "test2", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert config_entry.data[CONF_USERNAME] == "email2@test.com" + assert config_entry.data[CONF_PASSWORD] == "test2" + assert config_entry.data[CONF_KAMEREON_ACCOUNT_ID] == "account_id_1" + assert config_entry.data[CONF_LOCALE] == "fr_FR" + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure_mismatch( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure fails on account ID mismatch.""" + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + data_schema = result["data_schema"].schema + assert get_schema_suggested_value(data_schema, CONF_LOCALE) == "fr_FR" + assert get_schema_suggested_value(data_schema, CONF_USERNAME) == "email@test.com" + assert get_schema_suggested_value(data_schema, CONF_PASSWORD) == "test" + + renault_account = AsyncMock() + type(renault_account).account_id = PropertyMock(return_value="account_id_other") + renault_account.get_vehicles.return_value = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture("renault/vehicle_zoe_40.json") + ) + ) + + # Account list single + with ( + patch("renault_api.renault_session.RenaultSession.login"), + patch( + "renault_api.renault_account.RenaultAccount.account_id", return_value="1234" + ), + patch( + "renault_api.renault_client.RenaultClient.get_api_accounts", + return_value=[renault_account], + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email2@test.com", + CONF_PASSWORD: "test2", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + # Unchanged values + assert config_entry.data[CONF_USERNAME] == "email@test.com" + assert config_entry.data[CONF_PASSWORD] == "test" + assert config_entry.data[CONF_KAMEREON_ACCOUNT_ID] == "account_id_1" + assert config_entry.data[CONF_LOCALE] == "fr_FR" + + assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/renault/test_device_tracker.py b/tests/components/renault/test_device_tracker.py index 39f37d12a4d..090a73ae904 100644 --- a/tests/components/renault/test_device_tracker.py +++ b/tests/components/renault/test_device_tracker.py @@ -9,13 +9,17 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry 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 check_device_registry, check_entities_unavailable from .const import MOCK_VEHICLES +from tests.common import snapshot_platform + pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") +# Zoe 40 does not expose GPS information +_TEST_VEHICLES = [v for v in MOCK_VEHICLES if v != "zoe_40"] + @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: @@ -25,10 +29,10 @@ def override_platforms() -> Generator[None]: @pytest.mark.usefixtures("fixtures_with_data") +@pytest.mark.parametrize("vehicle_type", _TEST_VEHICLES, indirect=True) async def test_device_trackers( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -36,28 +40,14 @@ async def test_device_trackers( 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 - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_no_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_50"], indirect=True) async def test_device_tracker_empty( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -65,77 +55,47 @@ async def test_device_tracker_empty( 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 - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") +@pytest.mark.parametrize("vehicle_type", ["zoe_50"], indirect=True) async def test_device_tracker_errors( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault device trackers with temporary failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - - expected_entities = mock_vehicle[Platform.DEVICE_TRACKER] - assert len(entity_registry.entities) == len(expected_entities) - - check_entities_unavailable(hass, entity_registry, expected_entities) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_access_denied_exception") -@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +@pytest.mark.parametrize("vehicle_type", ["zoe_50"], indirect=True) async def test_device_tracker_access_denied( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for Renault device trackers with access denied failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - assert len(entity_registry.entities) == 0 @pytest.mark.usefixtures("fixtures_with_not_supported_exception") -@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +@pytest.mark.parametrize("vehicle_type", ["zoe_50"], indirect=True) async def test_device_tracker_not_supported( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for Renault device trackers with not supported failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - assert len(entity_registry.entities) == 0 diff --git a/tests/components/renault/test_diagnostics.py b/tests/components/renault/test_diagnostics.py index 7159de26b11..233a32f7af8 100644 --- a/tests/components/renault/test_diagnostics.py +++ b/tests/components/renault/test_diagnostics.py @@ -48,9 +48,7 @@ async def test_device_diagnostics( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - device = device_registry.async_get_device( - identifiers={(DOMAIN, "VF1AAAAA555777999")} - ) + device = device_registry.async_get_device(identifiers={(DOMAIN, "VF1ZOE40VIN")}) assert device is not None assert ( diff --git a/tests/components/renault/test_init.py b/tests/components/renault/test_init.py index a71192dda47..48cac8e1add 100644 --- a/tests/components/renault/test_init.py +++ b/tests/components/renault/test_init.py @@ -7,6 +7,7 @@ from unittest.mock import Mock, patch import aiohttp import pytest from renault_api.gigya.exceptions import GigyaException, InvalidCredentialsException +from syrupy.assertion import SnapshotAssertion from homeassistant.components.renault.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry, ConfigEntryState @@ -24,13 +25,8 @@ def override_platforms() -> Generator[None]: yield -@pytest.fixture(autouse=True, name="vehicle_type", params=["zoe_40"]) -def override_vehicle_type(request: pytest.FixtureRequest) -> str: - """Parametrize vehicle type.""" - return request.param - - @pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_setup_unload_entry( hass: HomeAssistant, config_entry: ConfigEntry ) -> None: @@ -119,6 +115,24 @@ async def test_setup_entry_missing_vehicle_details( assert config_entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") +async def test_device_registry( + hass: HomeAssistant, + config_entry: ConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device is correctly registered.""" + 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 + + @pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_registry_cleanup( @@ -130,7 +144,7 @@ async def test_registry_cleanup( """Test being able to remove a disconnected device.""" assert await async_setup_component(hass, "config", {}) entry_id = config_entry.entry_id - live_id = "VF1AAAAA555777999" + live_id = "VF1ZOE40VIN" dead_id = "VF1AAAAA555777888" assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 0 @@ -148,7 +162,7 @@ async def test_registry_cleanup( await hass.async_block_till_done() assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 - # Try to remove "VF1AAAAA555777999" - fails as it is live + # Try to remove "VF1ZOE40VIN" - fails as it is live device = device_registry.async_get_device(identifiers={(DOMAIN, live_id)}) client = await hass_ws_client(hass) response = await client.remove_device(device.id, entry_id) diff --git a/tests/components/renault/test_select.py b/tests/components/renault/test_select.py index 7b589d86863..b8ba3ef4b58 100644 --- a/tests/components/renault/test_select.py +++ b/tests/components/renault/test_select.py @@ -15,16 +15,19 @@ from homeassistant.components.select import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er -from . import check_device_registry, check_entities_unavailable from .const import MOCK_VEHICLES -from tests.common import load_fixture +from tests.common import load_fixture, snapshot_platform pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") +# Captur (fuel version) does not have a charge mode select +_TEST_VEHICLES = [v for v in MOCK_VEHICLES if v != "captur_fuel"] + + @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" @@ -33,10 +36,10 @@ def override_platforms() -> Generator[None]: @pytest.mark.usefixtures("fixtures_with_data") +@pytest.mark.parametrize("vehicle_type", _TEST_VEHICLES, indirect=True) async def test_selects( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -44,28 +47,14 @@ async def test_selects( 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 - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_no_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_select_empty( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -73,42 +62,22 @@ async def test_select_empty( 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 - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_select_errors( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault selects with temporary failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - - expected_entities = mock_vehicle[Platform.SELECT] - assert len(entity_registry.entities) == len(expected_entities) - - check_entities_unavailable(hass, entity_registry, expected_entities) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_access_denied_exception") @@ -116,17 +85,12 @@ async def test_select_errors( async def test_select_access_denied( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for Renault selects with access denied failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - assert len(entity_registry.entities) == 0 @@ -135,17 +99,12 @@ async def test_select_access_denied( async def test_select_not_supported( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for Renault selects with access denied failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - assert len(entity_registry.entities) == 0 @@ -159,7 +118,7 @@ async def test_select_charge_mode( await hass.async_block_till_done() data = { - ATTR_ENTITY_ID: "select.reg_number_charge_mode", + ATTR_ENTITY_ID: "select.reg_zoe_40_charge_mode", ATTR_OPTION: "always", } diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index d69ab5c0b7f..e75d0558f19 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -1,18 +1,26 @@ """Tests for Renault sensors.""" from collections.abc import Generator -from unittest.mock import patch +import datetime +from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest +from renault_api.kamereon.exceptions import ( + AccessDeniedException, + NotSupportedException, + QuotaLimitException, +) from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNAVAILABLE, 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 check_device_registry, check_entities_unavailable -from .const import MOCK_VEHICLES +from .conftest import _get_fixtures, patch_get_vehicle_data + +from tests.common import async_fire_time_changed, snapshot_platform pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -24,11 +32,10 @@ def override_platforms() -> Generator[None]: yield -@pytest.mark.usefixtures("fixtures_with_data") +@pytest.mark.usefixtures("fixtures_with_data", "entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -36,34 +43,14 @@ async def test_sensors( 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 - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - # 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 + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_no_data", "entity_registry_enabled_by_default") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_sensor_empty( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -71,47 +58,24 @@ async def test_sensor_empty( 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 - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures( "fixtures_with_invalid_upstream_exception", "entity_registry_enabled_by_default" ) +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_sensor_errors( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault sensors with temporary failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - - expected_entities = mock_vehicle[Platform.SENSOR] - assert len(entity_registry.entities) == len(expected_entities) - - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - - check_entities_unavailable(hass, entity_registry, expected_entities) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_access_denied_exception") @@ -119,17 +83,12 @@ async def test_sensor_errors( async def test_sensor_access_denied( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for Renault sensors with access denied failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - assert len(entity_registry.entities) == 0 @@ -138,15 +97,181 @@ async def test_sensor_access_denied( async def test_sensor_not_supported( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for Renault sensors with access denied failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - assert len(entity_registry.entities) == 0 + + +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_sensor_throttling_during_setup( + hass: HomeAssistant, + config_entry: ConfigEntry, + vehicle_type: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for Renault sensors with a throttling error during setup.""" + mock_fixtures = _get_fixtures(vehicle_type) + with patch_get_vehicle_data() as patches: + for key, get_data_mock in patches.items(): + get_data_mock.return_value = mock_fixtures[key] + get_data_mock.side_effect = QuotaLimitException( + "err.func.wired.overloaded", "You have reached your quota limit" + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Initial state + entity_id = "sensor.reg_zoe_40_battery" + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # Test QuotaLimitException recovery, with new battery level + for get_data_mock in patches.values(): + get_data_mock.side_effect = None + patches["battery_status"].return_value.batteryLevel = 55 + freezer.tick(datetime.timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "55" + + +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_sensor_throttling_after_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + vehicle_type: str, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for Renault sensors with a throttling error during setup.""" + mock_fixtures = _get_fixtures(vehicle_type) + with patch_get_vehicle_data() as patches: + for key, get_data_mock in patches.items(): + get_data_mock.return_value = mock_fixtures[key] + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Initial state + entity_id = "sensor.reg_zoe_40_battery" + assert hass.states.get(entity_id).state == "60" + assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled: scan skipped" not in caplog.text + + # Test QuotaLimitException state + caplog.clear() + for get_data_mock in patches.values(): + get_data_mock.side_effect = QuotaLimitException( + "err.func.wired.overloaded", "You have reached your quota limit" + ) + freezer.tick(datetime.timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "60" + assert hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled" in caplog.text + assert "Renault hub currently throttled: scan skipped" in caplog.text + + # Test QuotaLimitException recovery, with new battery level + caplog.clear() + for get_data_mock in patches.values(): + get_data_mock.side_effect = None + patches["battery_status"].return_value.batteryLevel = 55 + freezer.tick(datetime.timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "55" + assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled" not in caplog.text + assert "Renault hub currently throttled: scan skipped" not in caplog.text + + +# scan interval in seconds = (3600 * num_calls) / MAX_CALLS_PER_HOURS +# MAX_CALLS_PER_HOURS being a constant, for now 60 calls per hour +# num_calls = num_coordinator_car_0 + num_coordinator_car_1 + ... + num_coordinator_car_n +@pytest.mark.parametrize( + ("vehicle_type", "vehicle_count", "scan_interval"), + [ + ("zoe_50", 1, 300), # 5 coordinators => 5 minutes interval + ("captur_fuel", 1, 240), # 4 coordinators => 4 minutes interval + ("multi", 2, 480), # 8 coordinators => 8 minutes interval + ], + indirect=["vehicle_type"], +) +async def test_dynamic_scan_interval( + hass: HomeAssistant, + config_entry: ConfigEntry, + vehicle_count: int, + scan_interval: int, + freezer: FrozenDateTimeFactory, + fixtures_with_data: dict[str, AsyncMock], +) -> None: + """Test scan interval.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert fixtures_with_data["cockpit"].call_count == vehicle_count + + # 2 seconds before the expected scan interval > not called + freezer.tick(datetime.timedelta(seconds=scan_interval - 2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert fixtures_with_data["cockpit"].call_count == vehicle_count + + # 2 seconds after the expected scan interval > called + freezer.tick(datetime.timedelta(seconds=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert fixtures_with_data["cockpit"].call_count == vehicle_count * 2 + + +# scan interval in seconds = (3600 * num_calls) / MAX_CALLS_PER_HOURS +# MAX_CALLS_PER_HOURS being a constant, for now 60 calls per hour +# num_calls = num_coordinator_car_0 + num_coordinator_car_1 + ... + num_coordinator_car_n +@pytest.mark.parametrize( + ("vehicle_type", "vehicle_count", "scan_interval"), + [ + ("zoe_50", 1, 240), # (6-2) coordinators => 4 minutes interval + ("captur_fuel", 1, 180), # (4-1) coordinators => 3 minutes interval + ("multi", 2, 360), # (8-2) coordinators => 6 minutes interval + ], + indirect=["vehicle_type"], +) +async def test_dynamic_scan_interval_failed_coordinator( + hass: HomeAssistant, + config_entry: ConfigEntry, + vehicle_count: int, + scan_interval: int, + freezer: FrozenDateTimeFactory, + fixtures_with_data: dict[str, AsyncMock], +) -> None: + """Test scan interval.""" + fixtures_with_data["battery_status"].side_effect = NotSupportedException( + "err.tech.501", + "This feature is not technically supported by this gateway", + ) + fixtures_with_data["lock_status"].side_effect = AccessDeniedException( + "err.func.403", + "Access is denied for this resource", + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert fixtures_with_data["cockpit"].call_count == vehicle_count + + # 2 seconds before the expected scan interval > not called + freezer.tick(datetime.timedelta(seconds=scan_interval - 2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert fixtures_with_data["cockpit"].call_count == vehicle_count + + # 2 seconds after the expected scan interval > called + freezer.tick(datetime.timedelta(seconds=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert fixtures_with_data["cockpit"].call_count == vehicle_count * 2 diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index 970d7cf4ad8..1762210ec6f 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -22,19 +22,10 @@ from homeassistant.components.renault.services import ( SERVICE_CHARGE_SET_SCHEDULES, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_MODEL_ID, - ATTR_NAME, -) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr -from .const import MOCK_VEHICLES - from tests.common import load_fixture pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -56,7 +47,7 @@ def override_vehicle_type(request: pytest.FixtureRequest) -> str: def get_device_id(hass: HomeAssistant) -> str: """Get device_id.""" device_registry = dr.async_get(hass) - identifiers = {(DOMAIN, "VF1AAAAA555777999")} + identifiers = {(DOMAIN, "VF1ZOE40VIN")} device = device_registry.async_get_device(identifiers=identifiers) return device.id @@ -72,13 +63,14 @@ async def test_service_set_ac_cancel( ATTR_VEHICLE: get_device_id(hass), } - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.set_ac_stop", - side_effect=RenaultException("Didn't work"), - ) as mock_action, - pytest.raises(HomeAssistantError, match="Didn't work"), - ): + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_ac_stop", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_ac_stop.json") + ) + ), + ) as mock_action: await hass.services.async_call( DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True ) @@ -158,11 +150,12 @@ async def test_service_set_charge_schedule( } with ( + patch("renault_api.renault_vehicle.RenaultVehicle.get_full_endpoint"), patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charging_settings", - return_value=schemas.KamereonVehicleDataResponseSchema.loads( + "renault_api.renault_vehicle.RenaultVehicle.http_get", + return_value=schemas.KamereonResponseSchema.loads( load_fixture("renault/charging_settings.json") - ).get_attributes(schemas.KamereonVehicleChargingSettingsDataSchema), + ), ), patch( "renault_api.renault_vehicle.RenaultVehicle.set_charge_schedules", @@ -207,11 +200,12 @@ async def test_service_set_charge_schedule_multi( } with ( + patch("renault_api.renault_vehicle.RenaultVehicle.get_full_endpoint"), patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charging_settings", - return_value=schemas.KamereonVehicleDataResponseSchema.loads( + "renault_api.renault_vehicle.RenaultVehicle.http_get", + return_value=schemas.KamereonResponseSchema.loads( load_fixture("renault/charging_settings.json") - ).get_attributes(schemas.KamereonVehicleChargingSettingsDataSchema), + ), ), patch( "renault_api.renault_vehicle.RenaultVehicle.set_charge_schedules", @@ -337,7 +331,7 @@ async def test_service_set_ac_schedule_multi( async def test_service_invalid_device_id( hass: HomeAssistant, config_entry: ConfigEntry ) -> None: - """Test that service fails with ValueError if device_id not found in registry.""" + """Test that service fails if device_id not found in registry.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -354,22 +348,19 @@ async def test_service_invalid_device_id( async def test_service_invalid_device_id2( hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry: ConfigEntry ) -> None: - """Test that service fails with ValueError if device_id not found in vehicles.""" + """Test that service fails if device_id not available in the hub.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - extra_vehicle = MOCK_VEHICLES["captur_phev"]["expected_device"] - + # Create a fake second vehicle in the device registry, but + # not initialised by the hub. device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers=extra_vehicle[ATTR_IDENTIFIERS], - manufacturer=extra_vehicle[ATTR_MANUFACTURER], - name=extra_vehicle[ATTR_NAME], - model=extra_vehicle[ATTR_MODEL], - model_id=extra_vehicle[ATTR_MODEL_ID], + identifiers={(DOMAIN, "VF1AAAAA111222333")}, + name="REG-NUMBER", ) device_id = device_registry.async_get_device( - identifiers=extra_vehicle[ATTR_IDENTIFIERS] + identifiers={(DOMAIN, "VF1AAAAA111222333")}, ).id data = {ATTR_VEHICLE: device_id} @@ -380,3 +371,28 @@ async def test_service_invalid_device_id2( ) assert err.value.translation_key == "no_config_entry_for_device" assert err.value.translation_placeholders == {"device_id": "REG-NUMBER"} + + +async def test_service_exception( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Test that service invokes renault_api with correct data.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + data = { + ATTR_VEHICLE: get_device_id(hass), + } + + with ( + patch( + "renault_api.renault_vehicle.RenaultVehicle.set_ac_stop", + side_effect=RenaultException("Didn't work"), + ) as mock_action, + pytest.raises(HomeAssistantError, match="Didn't work"), + ): + await hass.services.async_call( + DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == () diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 3bd1539fc36..a2155ba00eb 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -138,6 +138,8 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.daynight_state.return_value = "Black&White" host_mock.hub_alarm_tone_id.return_value = 1 host_mock.hub_visitor_tone_id.return_value = 1 + host_mock.recording_packing_time_list = ["30 Minutes", "60 Minutes"] + host_mock.recording_packing_time = "60 Minutes" # Baichuan host_mock.baichuan = create_autospec(Baichuan) diff --git a/tests/components/repairs/__init__.py b/tests/components/repairs/__init__.py index e787d657e5c..7d5e4a43cd8 100644 --- a/tests/components/repairs/__init__.py +++ b/tests/components/repairs/__init__.py @@ -42,20 +42,20 @@ async def get_repairs( async def start_repair_fix_flow( - client: TestClient, handler: str, issue_id: int + client: TestClient, handler: str, issue_id: str ) -> dict[str, Any]: """Start a flow from an issue.""" url = RepairsFlowIndexView.url resp = await client.post(url, json={"handler": handler, "issue_id": issue_id}) - assert resp.status == HTTPStatus.OK + assert resp.status == HTTPStatus.OK, f"Error: {resp.status}, {await resp.text()}" return await resp.json() async def process_repair_fix_flow( - client: TestClient, flow_id: int, json: dict[str, Any] | None = None + client: TestClient, flow_id: str, json: dict[str, Any] | None = None ) -> dict[str, Any]: """Return the repairs list of issues.""" url = RepairsFlowResourceView.url.format(flow_id=flow_id) resp = await client.post(url, json=json) - assert resp.status == HTTPStatus.OK + assert resp.status == HTTPStatus.OK, f"Error: {resp.status}, {await resp.text()}" return await resp.json() diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py index 65ec6bf5c05..6992794d596 100644 --- a/tests/components/rest/test_binary_sensor.py +++ b/tests/components/rest/test_binary_sensor.py @@ -595,3 +595,53 @@ async def test_availability_in_config(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.rest_binary_sensor") assert state.state == STATE_UNAVAILABLE + + +@respx.mock +async def test_availability_blocks_value_template( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for binary_sensor.block_template: 'x' is undefined" + respx.get("http://localhost").respond(status_code=HTTPStatus.OK, content="51") + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "binary_sensor": [ + { + "unique_id": "block_template", + "name": "block_template", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + } + ], + } + ] + }, + ) + await hass.async_block_till_done() + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + assert error not in caplog.text + + state = hass.states.get("binary_sensor.block_template") + assert state + assert state.state == STATE_UNAVAILABLE + + respx.clear() + respx.get("http://localhost").respond(status_code=HTTPStatus.OK, content="50") + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["binary_sensor.block_template"]}, + blocking=True, + ) + await hass.async_block_till_done() + + assert error in caplog.text diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index d5fc5eca55c..81440125b12 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -1035,22 +1035,211 @@ async def test_entity_config( @respx.mock async def test_availability_in_config(hass: HomeAssistant) -> None: """Test entity configuration.""" - - config = { - SENSOR_DOMAIN: { - # REST configuration - "platform": DOMAIN, - "method": "GET", - "resource": "http://localhost", - # Entity configuration - "availability": "{{value==1}}", - "name": "{{'REST' + ' ' + 'Sensor'}}", + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={ + "state": "okay", + "available": True, + "name": "rest_sensor", + "icon": "mdi:foo", + "picture": "foo.jpg", }, - } + ) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "sensor": [ + { + "unique_id": "somethingunique", + "availability": "{{ value_json.available }}", + "value_template": "{{ value_json.state }}", + "name": "{{ value_json.name if value_json is defined else 'rest_sensor' }}", + "icon": "{{ value_json.icon }}", + "picture": "{{ value_json.picture }}", + } + ], + } + ] + }, + ) + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() - respx.get("http://localhost").respond(status_code=HTTPStatus.OK, text="123") - assert await async_setup_component(hass, SENSOR_DOMAIN, config) + state = hass.states.get("sensor.rest_sensor") + assert state.state == "okay" + assert state.attributes["friendly_name"] == "rest_sensor" + assert state.attributes["icon"] == "mdi:foo" + assert state.attributes["entity_picture"] == "foo.jpg" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={ + "state": "okay", + "available": False, + "name": "unavailable", + "icon": "mdi:unavailable", + "picture": "unavailable.jpg", + }, + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.rest_sensor"]}, + blocking=True, + ) await hass.async_block_till_done() state = hass.states.get("sensor.rest_sensor") assert state.state == STATE_UNAVAILABLE + assert "friendly_name" not in state.attributes + assert "icon" not in state.attributes + assert "entity_picture" not in state.attributes + + +@respx.mock +async def test_json_response_with_availability_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test availability with syntax error.""" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"heartbeatList": {"1": [{"status": 1, "ping": 21.4}]}}, + ) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "sensor": [ + { + "unique_id": "complex_json", + "name": "complex_json", + "value_template": '{% set v = value_json.heartbeatList["1"][-1] %}{{ v.ping }}', + "availability": "{{ what_the_heck == 2 }}", + } + ], + } + ] + }, + ) + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 + + state = hass.states.get("sensor.complex_json") + assert state.state == "21.4" + + assert ( + "Error rendering availability template for sensor.complex_json: UndefinedError: 'what_the_heck' is undefined" + in caplog.text + ) + + +@respx.mock +async def test_json_response_with_availability(hass: HomeAssistant) -> None: + """Test availability with complex json.""" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"heartbeatList": {"1": [{"status": 1, "ping": 21.4}]}}, + ) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "sensor": [ + { + "unique_id": "complex_json", + "name": "complex_json", + "value_template": '{% set v = value_json.heartbeatList["1"][-1] %}{{ v.ping }}', + "availability": '{% set v = value_json.heartbeatList["1"][-1] %}{{ v.status == 1 and is_number(v.ping) }}', + "unit_of_measurement": "ms", + "state_class": "measurement", + } + ], + } + ] + }, + ) + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 + + state = hass.states.get("sensor.complex_json") + assert state.state == "21.4" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"heartbeatList": {"1": [{"status": 0, "ping": None}]}}, + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.complex_json"]}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.complex_json") + assert state.state == STATE_UNAVAILABLE + + +@respx.mock +async def test_availability_blocks_value_template( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for sensor.block_template: 'x' is undefined" + respx.get("http://localhost").respond(status_code=HTTPStatus.OK, content="51") + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "sensor": [ + { + "unique_id": "block_template", + "name": "block_template", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + "unit_of_measurement": "ms", + "state_class": "measurement", + } + ], + } + ] + }, + ) + await hass.async_block_till_done() + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + assert error not in caplog.text + + state = hass.states.get("sensor.block_template") + assert state + assert state.state == STATE_UNAVAILABLE + + respx.clear() + respx.get("http://localhost").respond(status_code=HTTPStatus.OK, content="50") + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.block_template"]}, + blocking=True, + ) + await hass.async_block_till_done() + + assert error in caplog.text diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index e0fc36d053e..2a69f5a477a 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -37,6 +37,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -482,3 +483,122 @@ async def test_entity_config( ATTR_FRIENDLY_NAME: "REST Switch", ATTR_ICON: "mdi:one_two_three", } + + +@respx.mock +async def test_availability( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test entity configuration.""" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"beer": 1}, + ) + assert await async_setup_component( + hass, + SWITCH_DOMAIN, + { + SWITCH_DOMAIN: { + # REST configuration + CONF_PLATFORM: DOMAIN, + CONF_METHOD: "POST", + CONF_RESOURCE: "http://localhost", + # Entity configuration + CONF_NAME: "{{'REST' + ' ' + 'Switch'}}", + "is_on_template": "{{ value_json.beer == 1 }}", + "availability": "{{ value_json.beer is defined }}", + CONF_ICON: "mdi:{{ value_json.beer }}", + CONF_PICTURE: "{{ value_json.beer }}.png", + }, + }, + ) + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + state = hass.states.get("switch.rest_switch") + assert state + assert state.state == STATE_ON + assert state.attributes["icon"] == "mdi:1" + assert state.attributes["entity_picture"] == "1.png" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"x": 1}, + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["switch.rest_switch"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("switch.rest_switch") + assert state + assert state.state == STATE_UNAVAILABLE + assert "icon" not in state.attributes + assert "entity_picture" not in state.attributes + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"beer": 0}, + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["switch.rest_switch"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("switch.rest_switch") + assert state + assert state.state == STATE_OFF + assert state.attributes["icon"] == "mdi:0" + assert state.attributes["entity_picture"] == "0.png" + + +@respx.mock +async def test_availability_blocks_is_on_template( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks is_on_template from rendering.""" + error = "Error parsing value for switch.block_template: 'x' is undefined" + respx.get(RESOURCE).respond(status_code=HTTPStatus.OK, content="51") + config = { + SWITCH_DOMAIN: { + # REST configuration + CONF_PLATFORM: DOMAIN, + CONF_METHOD: "POST", + CONF_RESOURCE: "http://localhost", + # Entity configuration + CONF_NAME: "block_template", + "is_on_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + }, + } + + assert await async_setup_component(hass, SWITCH_DOMAIN, config) + await hass.async_block_till_done() + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + assert error not in caplog.text + + state = hass.states.get("switch.block_template") + assert state + assert state.state == STATE_UNAVAILABLE + + respx.clear() + respx.get("http://localhost").respond(status_code=HTTPStatus.OK, content="50") + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["switch.block_template"]}, + blocking=True, + ) + await hass.async_block_till_done() + + assert error in caplog.text diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 1ec2b00263f..d807e35710b 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -28,6 +28,7 @@ from .mock_data import ( MULTI_MAP_LIST, NETWORK_INFO, PROP, + ROBOROCK_RRUID, SCENES, USER_DATA, USER_EMAIL, @@ -188,18 +189,28 @@ def bypass_api_fixture_v1_only(bypass_api_fixture) -> None: yield +@pytest.fixture(name="config_entry_data") +def config_entry_data_fixture() -> dict[str, Any]: + """Fixture that returns the unique id for the config entry.""" + return { + CONF_USERNAME: USER_EMAIL, + CONF_USER_DATA: USER_DATA.as_dict(), + CONF_BASE_URL: BASE_URL, + } + + @pytest.fixture -def mock_roborock_entry(hass: HomeAssistant) -> MockConfigEntry: +def mock_roborock_entry( + hass: HomeAssistant, config_entry_data: dict[str, Any] +) -> MockConfigEntry: """Create a Roborock Entry that has not been setup.""" mock_entry = MockConfigEntry( domain=DOMAIN, title=USER_EMAIL, - data={ - CONF_USERNAME: USER_EMAIL, - CONF_USER_DATA: USER_DATA.as_dict(), - CONF_BASE_URL: BASE_URL, - }, - unique_id=USER_EMAIL, + data=config_entry_data, + unique_id=ROBOROCK_RRUID, + version=1, + minor_version=2, ) mock_entry.add_to_hass(hass) return mock_entry @@ -211,18 +222,26 @@ def mock_platforms() -> list[Platform]: return [] +@pytest.fixture(autouse=True) +async def mock_patforms_fixture( + hass: HomeAssistant, + platforms: list[Platform], +) -> Generator[None]: + """Set up the Roborock platform.""" + with patch("homeassistant.components.roborock.PLATFORMS", platforms): + yield + + @pytest.fixture async def setup_entry( hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry, - platforms: list[Platform], ) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" - with patch("homeassistant.components.roborock.PLATFORMS", platforms): - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) - await hass.async_block_till_done() - yield mock_roborock_entry + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + return mock_roborock_entry @pytest.fixture(autouse=True, name="storage_path") diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 82b51e67f8d..cf4f167ef7f 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -28,6 +28,7 @@ USER_EMAIL = "user@domain.com" BASE_URL = "https://usiot.roborock.com" +ROBOROCK_RRUID = "roboborock-userid-abc-123" USER_DATA = UserData.from_dict( { "tuyaname": "abc123", @@ -35,7 +36,7 @@ USER_DATA = UserData.from_dict( "uid": 123456, "tokentype": "", "token": "abc123", - "rruid": "abc123", + "rruid": ROBOROCK_RRUID, "region": "us", "countrycode": "1", "country": "US", diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 441974dc15d..7958f17a696 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -16,12 +16,12 @@ from vacuum_map_parser_base.config.drawable import Drawable from homeassistant import config_entries from homeassistant.components.roborock.const import CONF_ENTRY_CODE, DOMAIN, DRAWABLES -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .mock_data import MOCK_CONFIG, NETWORK_INFO, USER_DATA, USER_EMAIL +from .mock_data import MOCK_CONFIG, NETWORK_INFO, ROBOROCK_RRUID, USER_DATA, USER_EMAIL from tests.common import MockConfigEntry @@ -64,6 +64,7 @@ async def test_config_flow_success( ) assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == ROBOROCK_RRUID assert result["title"] == USER_EMAIL assert result["data"] == MOCK_CONFIG assert result["result"] @@ -128,6 +129,7 @@ async def test_config_flow_failures_request_code( ) assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == ROBOROCK_RRUID assert result["title"] == USER_EMAIL assert result["data"] == MOCK_CONFIG assert result["result"] @@ -189,6 +191,7 @@ async def test_config_flow_failures_code_login( ) assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == ROBOROCK_RRUID assert result["title"] == USER_EMAIL assert result["data"] == MOCK_CONFIG assert result["result"] @@ -256,6 +259,7 @@ async def test_reauth_flow( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + assert mock_roborock_entry.unique_id == ROBOROCK_RRUID assert mock_roborock_entry.data["user_data"]["rriot"]["s"] == "new_password_hash" @@ -264,7 +268,8 @@ async def test_account_already_configured( bypass_api_fixture, mock_roborock_entry: MockConfigEntry, ) -> None: - """Handle the config flow and make sure it succeeds.""" + """Ensure the same account cannot be setup twice.""" + assert mock_roborock_entry.unique_id == ROBOROCK_RRUID with patch( "homeassistant.components.roborock.async_setup_entry", return_value=True ): @@ -280,10 +285,59 @@ async def test_account_already_configured( result["flow_id"], {CONF_USERNAME: USER_EMAIL} ) + assert result["step_id"] == "code" + assert result["type"] is FlowResultType.FORM + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=USER_DATA, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured_account" +async def test_reauth_wrong_account( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Ensure that reauthentication must use the same account.""" + + # Start reauth + result = mock_roborock_entry.async_start_reauth(hass) + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.roborock.async_setup_entry", return_value=True + ): + 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["step_id"] == "code" + assert result["type"] is FlowResultType.FORM + new_user_data = deepcopy(USER_DATA) + new_user_data.rruid = "new_rruid" + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=new_user_data, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + async def test_discovery_not_setup( hass: HomeAssistant, bypass_api_fixture, @@ -322,11 +376,13 @@ async def test_discovery_not_setup( ) assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == ROBOROCK_RRUID assert result["title"] == USER_EMAIL assert result["data"] == MOCK_CONFIG assert result["result"] +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) async def test_discovery_already_setup( hass: HomeAssistant, bypass_api_fixture, @@ -346,3 +402,4 @@ async def test_discovery_already_setup( ) assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/roborock/test_coordinator.py b/tests/components/roborock/test_coordinator.py index 94976ba92f5..dec4e0a62d4 100644 --- a/tests/components/roborock/test_coordinator.py +++ b/tests/components/roborock/test_coordinator.py @@ -13,6 +13,7 @@ from homeassistant.components.roborock.const import ( V1_LOCAL_IN_CLEANING_INTERVAL, V1_LOCAL_NOT_CLEANING_INTERVAL, ) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -21,6 +22,12 @@ from .mock_data import PROP from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.SENSOR] + + @pytest.mark.parametrize( ("interval", "in_cleaning"), [ diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 983e3d083f4..a1bcfc462e4 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -3,6 +3,7 @@ from copy import deepcopy from http import HTTPStatus import pathlib +from typing import Any from unittest.mock import patch import pytest @@ -20,7 +21,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component -from .mock_data import HOME_DATA, NETWORK_INFO, NETWORK_INFO_2 +from .mock_data import ( + HOME_DATA, + NETWORK_INFO, + NETWORK_INFO_2, + ROBOROCK_RRUID, + USER_EMAIL, +) from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -300,6 +307,7 @@ async def test_no_user_agreement( assert mock_roborock_entry.error_reason_translation_key == "no_user_agreement" +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) async def test_stale_device( hass: HomeAssistant, bypass_api_fixture, @@ -341,6 +349,7 @@ async def test_stale_device( # therefore not deleted. +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) async def test_no_stale_device( hass: HomeAssistant, bypass_api_fixture, @@ -369,3 +378,25 @@ async def test_no_stale_device( mock_roborock_entry.entry_id ) assert len(new_devices) == 6 # 2 for each robot, 1 for A01, 1 for Zeo + + +async def test_migrate_config_entry_unique_id( + hass: HomeAssistant, + bypass_api_fixture, + config_entry_data: dict[str, Any], +) -> None: + """Test migrating the config entry unique id.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=USER_EMAIL, + data=config_entry_data, + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.unique_id == ROBOROCK_RRUID diff --git a/tests/components/roku/test_binary_sensor.py b/tests/components/roku/test_binary_sensor.py index ad27a857101..c3aec4f0968 100644 --- a/tests/components/roku/test_binary_sensor.py +++ b/tests/components/roku/test_binary_sensor.py @@ -50,7 +50,7 @@ async def test_roku_binary_sensors( assert entry.unique_id == f"{UPNP_SERIAL}_supports_ethernet" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == STATE_ON - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Supports ethernet" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Supports Ethernet" assert ATTR_DEVICE_CLASS not in state.attributes state = hass.states.get("binary_sensor.my_roku_3_supports_find_remote") @@ -125,7 +125,7 @@ async def test_rokutv_binary_sensors( assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == STATE_ON assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Supports ethernet' + state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Supports Ethernet' ) assert ATTR_DEVICE_CLASS not in state.attributes diff --git a/tests/components/rss_feed_template/test_init.py b/tests/components/rss_feed_template/test_init.py index 802fbb2244b..3b708b577af 100644 --- a/tests/components/rss_feed_template/test_init.py +++ b/tests/components/rss_feed_template/test_init.py @@ -1,6 +1,5 @@ """The tests for the rss_feed_api component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus from aiohttp.test_utils import TestClient @@ -14,13 +13,11 @@ from tests.typing import ClientSessionGenerator @pytest.fixture -def mock_http_client( - event_loop: AbstractEventLoop, +async def mock_http_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Set up test fixture.""" - loop = event_loop config = { "rss_feed_template": { "testfeed": { @@ -35,8 +32,8 @@ def mock_http_client( } } - loop.run_until_complete(async_setup_component(hass, "rss_feed_template", config)) - return loop.run_until_complete(hass_client()) + await async_setup_component(hass, "rss_feed_template", config) + return await hass_client() async def test_get_nonexistant_feed(mock_http_client) -> None: diff --git a/tests/components/samsungtv/__init__.py b/tests/components/samsungtv/__init__.py index f77cd7a9b3e..182ea850b52 100644 --- a/tests/components/samsungtv/__init__.py +++ b/tests/components/samsungtv/__init__.py @@ -2,30 +2,25 @@ from __future__ import annotations -from datetime import timedelta +from collections.abc import Mapping +from typing import Any -from homeassistant.components.samsungtv.const import DOMAIN, ENTRY_RELOAD_COOLDOWN +from homeassistant.components.samsungtv.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType -from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry -async def async_wait_config_entry_reload(hass: HomeAssistant) -> None: - """Wait for the config entry to reload.""" - await hass.async_block_till_done() - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) - ) - await hass.async_block_till_done() - - -async def setup_samsungtv_entry(hass: HomeAssistant, data: ConfigType) -> ConfigEntry: +async def setup_samsungtv_entry( + hass: HomeAssistant, data: Mapping[str, Any] +) -> ConfigEntry: """Set up mock Samsung TV from config entry data.""" entry = MockConfigEntry( - domain=DOMAIN, data=data, entry_id="123456", unique_id="any" + domain=DOMAIN, + data=data, + entry_id="123456", + unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 105ef0f25ad..4b3ad59defd 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Generator -from datetime import datetime from socket import AddressFamily # pylint: disable=no-name-in-module from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -21,7 +20,6 @@ from samsungtvws.exceptions import ResponseError from samsungtvws.remote import ChannelEmitCommand from homeassistant.components.samsungtv.const import WEBSOCKET_SSL_PORT -from homeassistant.util import dt as dt_util from .const import SAMPLE_DEVICE_INFO_UE48JU6400, SAMPLE_DEVICE_INFO_WIFI @@ -53,7 +51,7 @@ def silent_ssdp_scanner() -> Generator[None]: @pytest.fixture(autouse=True) -def samsungtv_mock_async_get_local_ip(): +def samsungtv_mock_async_get_local_ip() -> Generator[None]: """Mock upnp util's async_get_local_ip.""" with patch( "homeassistant.components.samsungtv.media_player.async_get_local_ip", @@ -63,7 +61,7 @@ def samsungtv_mock_async_get_local_ip(): @pytest.fixture(autouse=True) -def fake_host_fixture() -> None: +def fake_host_fixture() -> Generator[None]: """Patch gethostbyname.""" with patch( "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", @@ -73,14 +71,14 @@ def fake_host_fixture() -> None: @pytest.fixture(autouse=True) -def app_list_delay_fixture() -> None: +def app_list_delay_fixture() -> Generator[None]: """Patch APP_LIST_DELAY.""" with patch("homeassistant.components.samsungtv.media_player.APP_LIST_DELAY", 0): yield @pytest.fixture(name="upnp_factory", autouse=True) -def upnp_factory_fixture() -> Mock: +def upnp_factory_fixture() -> Generator[Mock]: """Patch UpnpFactory.""" with patch( "homeassistant.components.samsungtv.media_player.UpnpFactory", @@ -92,17 +90,17 @@ def upnp_factory_fixture() -> Mock: @pytest.fixture(name="upnp_device") -async def upnp_device_fixture(upnp_factory: Mock) -> Mock: +def upnp_device_fixture(upnp_factory: Mock) -> Mock: """Patch async_upnp_client.""" upnp_device = Mock(UpnpDevice) upnp_device.services = {} - with patch.object(upnp_factory, "async_create_device", side_effect=[upnp_device]): - yield upnp_device + upnp_factory.async_create_device.side_effect = [upnp_device] + return upnp_device @pytest.fixture(name="dmr_device") -async def dmr_device_fixture(upnp_device: Mock) -> Mock: +def dmr_device_fixture(upnp_device: Mock) -> Generator[Mock]: """Patch async_upnp_client.""" with patch( "homeassistant.components.samsungtv.media_player.DmrDevice", @@ -137,7 +135,7 @@ async def dmr_device_fixture(upnp_device: Mock) -> Mock: @pytest.fixture(name="upnp_notify_server") -async def upnp_notify_server_fixture(upnp_factory: Mock) -> Mock: +def upnp_notify_server_fixture(upnp_factory: Mock) -> Generator[Mock]: """Patch async_upnp_client.""" with patch( "homeassistant.components.samsungtv.media_player.AiohttpNotifyServer", @@ -149,7 +147,7 @@ async def upnp_notify_server_fixture(upnp_factory: Mock) -> Mock: @pytest.fixture(name="remote") -def remote_fixture() -> Mock: +def remote_fixture() -> Generator[Mock]: """Patch the samsungctl Remote.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote_class: remote = Mock(Remote) @@ -160,7 +158,7 @@ def remote_fixture() -> Mock: @pytest.fixture(name="rest_api") -def rest_api_fixture() -> Mock: +def rest_api_fixture() -> Generator[Mock]: """Patch the samsungtvws SamsungTVAsyncRest.""" with patch( "homeassistant.components.samsungtv.bridge.SamsungTVAsyncRest", @@ -173,7 +171,7 @@ def rest_api_fixture() -> Mock: @pytest.fixture(name="rest_api_non_ssl_only") -def rest_api_fixture_non_ssl_only() -> Mock: +def rest_api_fixture_non_ssl_only() -> Generator[None]: """Patch the samsungtvws SamsungTVAsyncRest non-ssl only.""" class MockSamsungTVAsyncRest: @@ -198,7 +196,7 @@ def rest_api_fixture_non_ssl_only() -> Mock: @pytest.fixture(name="rest_api_failing") -def rest_api_failure_fixture() -> Mock: +def rest_api_failure_fixture() -> Generator[None]: """Patch the samsungtvws SamsungTVAsyncRest.""" with patch( "homeassistant.components.samsungtv.bridge.SamsungTVAsyncRest", @@ -209,7 +207,7 @@ def rest_api_failure_fixture() -> Mock: @pytest.fixture(name="remoteencws_failing") -def remoteencws_failing_fixture(): +def remoteencws_failing_fixture() -> Generator[None]: """Patch the samsungtvws SamsungTVEncryptedWSAsyncRemote.""" with patch( "homeassistant.components.samsungtv.bridge.SamsungTVEncryptedWSAsyncRemote.start_listening", @@ -219,7 +217,7 @@ def remoteencws_failing_fixture(): @pytest.fixture(name="remotews") -def remotews_fixture() -> Mock: +def remotews_fixture() -> Generator[Mock]: """Patch the samsungtvws SamsungTVWS.""" remotews = Mock(SamsungTVWSAsyncRemote) remotews.__aenter__ = AsyncMock(return_value=remotews) @@ -260,7 +258,7 @@ def remotews_fixture() -> Mock: @pytest.fixture(name="remoteencws") -def remoteencws_fixture() -> Mock: +def remoteencws_fixture() -> Generator[Mock]: """Patch the samsungtvws SamsungTVEncryptedWSAsyncRemote.""" remoteencws = Mock(SamsungTVEncryptedWSAsyncRemote) remoteencws.__aenter__ = AsyncMock(return_value=remoteencws) @@ -285,14 +283,8 @@ def remoteencws_fixture() -> Mock: yield remoteencws -@pytest.fixture -def mock_now() -> datetime: - """Fixture for dtutil.now.""" - return dt_util.utcnow() - - @pytest.fixture(name="mac_address", autouse=True) -def mac_address_fixture() -> Mock: +def mac_address_fixture() -> Generator[Mock]: """Patch getmac.get_mac_address.""" with patch("getmac.get_mac_address", return_value=None) as mac: yield mac diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr index ad01b5454ff..db175626d41 100644 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -4,7 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , - 'config_subentries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -47,7 +47,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , - 'config_subentries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 576a5f6d534..5ff259c2120 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -41,6 +41,7 @@ from homeassistant.const import ( CONF_METHOD, CONF_MODEL, CONF_NAME, + CONF_PIN, CONF_PORT, CONF_TOKEN, ) @@ -172,7 +173,7 @@ AUTODETECT_LEGACY = { "description": "HomeAssistant", "id": "ha.component.samsung", "method": "legacy", - "port": None, + "port": LEGACY_PORT, "host": "fake_host", "timeout": TIMEOUT_REQUEST, } @@ -324,13 +325,13 @@ async def test_user_encrypted_websocket( assert result2["step_id"] == "encrypted_pairing" result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], user_input={"pin": "invalid"} + result2["flow_id"], user_input={CONF_PIN: "invalid"} ) assert result3["step_id"] == "encrypted_pairing" assert result3["errors"] == {"base": "invalid_pin"} result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], user_input={"pin": "1234"} + result3["flow_id"], user_input={CONF_PIN: "1234"} ) assert result4["type"] is FlowResultType.CREATE_ENTRY @@ -728,13 +729,13 @@ async def test_ssdp_encrypted_websocket_success_populates_mac_address_and_ssdp_l assert result2["step_id"] == "encrypted_pairing" result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], user_input={"pin": "invalid"} + result2["flow_id"], user_input={CONF_PIN: "invalid"} ) assert result3["step_id"] == "encrypted_pairing" assert result3["errors"] == {"base": "invalid_pin"} result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], user_input={"pin": "1234"} + result3["flow_id"], user_input={CONF_PIN: "1234"} ) assert result4["type"] is FlowResultType.CREATE_ENTRY @@ -1947,14 +1948,14 @@ async def test_form_reauth_encrypted(hass: HomeAssistant) -> None: # Invalid PIN result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"pin": "invalid"} + result["flow_id"], user_input={CONF_PIN: "invalid"} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm_encrypted" # Valid PIN result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"pin": "1234"} + result["flow_id"], user_input={CONF_PIN: "1234"} ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/samsungtv/test_device_trigger.py b/tests/components/samsungtv/test_device_trigger.py index fa6efd08076..e67f154cae1 100644 --- a/tests/components/samsungtv/test_device_trigger.py +++ b/tests/components/samsungtv/test_device_trigger.py @@ -28,7 +28,9 @@ async def test_get_triggers( """Test we get the expected triggers.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) - device = device_registry.async_get_device(identifiers={(DOMAIN, "any")}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, "be9554b9-c9fb-41f4-8920-22da015376a4")} + ) turn_on_trigger = { "platform": "device", @@ -54,7 +56,9 @@ async def test_if_fires_on_turn_on_request( await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) entity_id = "media_player.fake" - device = device_registry.async_get_device(identifiers={(DOMAIN, "any")}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, "be9554b9-c9fb-41f4-8920-22da015376a4")} + ) assert await async_setup_component( hass, diff --git a/tests/components/samsungtv/test_diagnostics.py b/tests/components/samsungtv/test_diagnostics.py index e8e0b699a7e..53d52456de5 100644 --- a/tests/components/samsungtv/test_diagnostics.py +++ b/tests/components/samsungtv/test_diagnostics.py @@ -53,7 +53,7 @@ async def test_entry_diagnostics( "source": "user", "subentries": [], "title": "Mock Title", - "unique_id": "any", + "unique_id": "be9554b9-c9fb-41f4-8920-22da015376a4", "version": 2, }, "device_info": SAMPLE_DEVICE_INFO_WIFI, @@ -94,7 +94,7 @@ async def test_entry_diagnostics_encrypted( "source": "user", "subentries": [], "title": "Mock Title", - "unique_id": "any", + "unique_id": "be9554b9-c9fb-41f4-8920-22da015376a4", "version": 2, }, "device_info": SAMPLE_DEVICE_INFO_UE48JU6400, @@ -134,7 +134,7 @@ async def test_entry_diagnostics_encrypte_offline( "source": "user", "subentries": [], "title": "Mock Title", - "unique_id": "any", + "unique_id": "be9554b9-c9fb-41f4-8920-22da015376a4", "version": 2, }, "device_info": None, diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 5715bd4b0aa..9f1efc0f013 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -72,7 +72,7 @@ async def test_setup(hass: HomeAssistant) -> None: == SUPPORT_SAMSUNGTV | MediaPlayerEntityFeature.TURN_ON ) - # test host and port + # Ensure service is registered await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -235,7 +235,7 @@ async def test_cleanup_mac( domain=DOMAIN, data=MOCK_ENTRY_WS_WITH_MAC, entry_id="123456", - unique_id="any", + unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", version=2, minor_version=1, ) @@ -248,7 +248,7 @@ async def test_cleanup_mac( (dr.CONNECTION_NETWORK_MAC, "none"), (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), }, - identifiers={("samsungtv", "any")}, + identifiers={("samsungtv", "be9554b9-c9fb-41f4-8920-22da015376a4")}, model="82GXARRS", name="fake", ) diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 3d9633bbf96..10e5249aac3 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -1,7 +1,7 @@ """Tests for samsungtv component.""" from copy import deepcopy -from datetime import datetime, timedelta +from datetime import timedelta import logging from unittest.mock import DEFAULT as DEFAULT_MOCK, AsyncMock, Mock, call, patch @@ -41,6 +41,7 @@ from homeassistant.components.samsungtv.const import ( CONF_SSDP_RENDERING_CONTROL_LOCATION, DOMAIN, ENCRYPTED_WEBSOCKET_PORT, + ENTRY_RELOAD_COOLDOWN, METHOD_ENCRYPTED_WEBSOCKET, METHOD_WEBSOCKET, TIMEOUT_WEBSOCKET, @@ -78,9 +79,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util -from . import async_wait_config_entry_reload, setup_samsungtv_entry +from . import setup_samsungtv_entry from .const import ( MOCK_CONFIG, MOCK_ENTRY_WS_WITH_MAC, @@ -153,7 +153,7 @@ async def test_setup_websocket(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("rest_api") async def test_setup_websocket_2( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test setup of platform from config entry.""" entity_id = f"{MP_DOMAIN}.fake" @@ -182,9 +182,8 @@ async def test_setup_websocket_2( assert config_entries[0].data[CONF_MAC] == "aa:bb:aa:aa:aa:aa" - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(entity_id) @@ -194,7 +193,7 @@ async def test_setup_websocket_2( @pytest.mark.usefixtures("rest_api") async def test_setup_encrypted_websocket( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test setup of platform from config entry.""" with patch( @@ -207,9 +206,8 @@ async def test_setup_encrypted_websocket( await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -218,15 +216,12 @@ async def test_setup_encrypted_websocket( @pytest.mark.usefixtures("remote") -async def test_update_on( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime -) -> None: +async def test_update_on(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Testing update tv on.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -234,9 +229,7 @@ async def test_update_on( @pytest.mark.usefixtures("remote") -async def test_update_off( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime -) -> None: +async def test_update_off(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -244,9 +237,8 @@ async def test_update_off( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[OSError("Boom"), DEFAULT_MOCK], ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -254,11 +246,7 @@ async def test_update_off( async def test_update_off_ws_no_power_state( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - remotews: Mock, - rest_api: Mock, - mock_now: datetime, + hass: HomeAssistant, freezer: FrozenDateTimeFactory, remotews: Mock, rest_api: Mock ) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -272,9 +260,8 @@ async def test_update_off_ws_no_power_state( remotews.start_listening = Mock(side_effect=WebSocketException("Boom")) remotews.is_alive.return_value = False - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -284,11 +271,7 @@ async def test_update_off_ws_no_power_state( @pytest.mark.usefixtures("remotews") async def test_update_off_ws_with_power_state( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - remotews: Mock, - rest_api: Mock, - mock_now: datetime, + hass: HomeAssistant, freezer: FrozenDateTimeFactory, remotews: Mock, rest_api: Mock ) -> None: """Testing update tv off.""" with ( @@ -311,9 +294,9 @@ async def test_update_off_ws_with_power_state( device_info = deepcopy(SAMPLE_DEVICE_INFO_WIFI) device_info["device"]["PowerState"] = "on" rest_api.rest_device_info.return_value = device_info - next_update = mock_now + timedelta(minutes=1) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) remotews.start_listening.assert_called_once() @@ -327,9 +310,9 @@ async def test_update_off_ws_with_power_state( # Second update uses device_info(ON) rest_api.rest_device_info.reset_mock() - next_update = mock_now + timedelta(minutes=2) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) rest_api.rest_device_info.assert_called_once() @@ -340,9 +323,9 @@ async def test_update_off_ws_with_power_state( # Third update uses device_info (OFF) rest_api.rest_device_info.reset_mock() device_info["device"]["PowerState"] = "off" - next_update = mock_now + timedelta(minutes=3) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) rest_api.rest_device_info.assert_called_once() @@ -358,7 +341,6 @@ async def test_update_off_encryptedws( freezer: FrozenDateTimeFactory, remoteencws: Mock, rest_api: Mock, - mock_now: datetime, ) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) @@ -371,9 +353,8 @@ async def test_update_off_encryptedws( remoteencws.start_listening = Mock(side_effect=WebSocketException("Boom")) remoteencws.is_alive.return_value = False - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -383,7 +364,7 @@ async def test_update_off_encryptedws( @pytest.mark.usefixtures("remote") async def test_update_access_denied( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Testing update tv access denied exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -392,14 +373,12 @@ async def test_update_access_denied( "homeassistant.components.samsungtv.bridge.Remote", side_effect=exceptions.AccessDenied("Boom"), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - next_update = mock_now + timedelta(minutes=10) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert [ @@ -415,7 +394,6 @@ async def test_update_access_denied( async def test_update_ws_connection_failure( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - mock_now: datetime, remotews: Mock, caplog: pytest.LogCaptureFixture, ) -> None: @@ -430,9 +408,8 @@ async def test_update_ws_connection_failure( ), patch.object(remotews, "is_alive", return_value=False), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert ( @@ -447,10 +424,7 @@ async def test_update_ws_connection_failure( @pytest.mark.usefixtures("rest_api") async def test_update_ws_connection_closed( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_now: datetime, - remotews: Mock, + hass: HomeAssistant, freezer: FrozenDateTimeFactory, remotews: Mock ) -> None: """Testing update tv connection failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -461,9 +435,8 @@ async def test_update_ws_connection_closed( ), patch.object(remotews, "is_alive", return_value=False), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -472,10 +445,7 @@ async def test_update_ws_connection_closed( @pytest.mark.usefixtures("rest_api") async def test_update_ws_unauthorized_error( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_now: datetime, - remotews: Mock, + hass: HomeAssistant, freezer: FrozenDateTimeFactory, remotews: Mock ) -> None: """Testing update tv unauthorized failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -484,9 +454,8 @@ async def test_update_ws_unauthorized_error( patch.object(remotews, "start_listening", side_effect=UnauthorizedError), patch.object(remotews, "is_alive", return_value=False), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert [ @@ -500,7 +469,7 @@ async def test_update_ws_unauthorized_error( @pytest.mark.usefixtures("remote") async def test_update_unhandled_response( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Testing update tv unhandled response exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -509,9 +478,8 @@ async def test_update_unhandled_response( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[exceptions.UnhandledResponse("Boom"), DEFAULT_MOCK], ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -520,7 +488,7 @@ async def test_update_unhandled_response( @pytest.mark.usefixtures("remote") async def test_connection_closed_during_update_can_recover( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Testing update tv connection closed exception can recover.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -529,17 +497,15 @@ async def test_connection_closed_during_update_can_recover( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[exceptions.ConnectionClosed(), DEFAULT_MOCK], ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == STATE_UNAVAILABLE - next_update = mock_now + timedelta(minutes=10) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -689,13 +655,12 @@ async def test_state(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> Non # Should be STATE_UNAVAILABLE after the timer expires assert state.state == STATE_OFF - next_update = dt_util.utcnow() + timedelta(seconds=20) with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError, ): - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -1005,7 +970,7 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, data=MOCK_ENTRY_WS_WITH_MAC, - unique_id="any", + unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1190,7 +1155,10 @@ async def test_select_source_app(hass: HomeAssistant, remotews: Mock) -> None: @pytest.mark.usefixtures("rest_api") async def test_websocket_unsupported_remote_control( - hass: HomeAssistant, remotews: Mock, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + remotews: Mock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, ) -> None: """Test for turn_off.""" entry = await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) @@ -1224,7 +1192,12 @@ async def test_websocket_unsupported_remote_control( "'unrecognized method value : ms.remote.control'" in caplog.text ) - await async_wait_config_entry_reload(hass) + # Wait config_entry reload + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + # ensure reauth triggered, and method/port updated assert [ flow @@ -1390,7 +1363,6 @@ async def test_upnp_re_subscribe_events( freezer: FrozenDateTimeFactory, remotews: Mock, dmr_device: Mock, - mock_now: datetime, ) -> None: """Test for Upnp event feedback.""" await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) @@ -1406,9 +1378,8 @@ async def test_upnp_re_subscribe_events( ), patch.object(remotews, "is_alive", return_value=False), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -1416,9 +1387,8 @@ async def test_upnp_re_subscribe_events( assert dmr_device.async_subscribe_services.call_count == 1 assert dmr_device.async_unsubscribe_services.call_count == 1 - next_update = mock_now + timedelta(minutes=10) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -1437,7 +1407,6 @@ async def test_upnp_failed_re_subscribe_events( freezer: FrozenDateTimeFactory, remotews: Mock, dmr_device: Mock, - mock_now: datetime, caplog: pytest.LogCaptureFixture, error: Exception, ) -> None: @@ -1455,9 +1424,8 @@ async def test_upnp_failed_re_subscribe_events( ), patch.object(remotews, "is_alive", return_value=False), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -1465,10 +1433,9 @@ async def test_upnp_failed_re_subscribe_events( assert dmr_device.async_subscribe_services.call_count == 1 assert dmr_device.async_unsubscribe_services.call_count == 1 - next_update = mock_now + timedelta(minutes=10) with patch.object(dmr_device, "async_subscribe_services", side_effect=error): - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) diff --git a/tests/components/samsungtv/test_remote.py b/tests/components/samsungtv/test_remote.py index da7871ca9c5..65474979968 100644 --- a/tests/components/samsungtv/test_remote.py +++ b/tests/components/samsungtv/test_remote.py @@ -39,7 +39,7 @@ async def test_unique_id( await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) main = entity_registry.async_get(ENTITY_ID) - assert main.unique_id == "any" + assert main.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" @pytest.mark.usefixtures("remoteencws", "rest_api") @@ -104,7 +104,7 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, data=MOCK_ENTRY_WS_WITH_MAC, - unique_id="any", + unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py index e1d26043bb0..d957e501775 100644 --- a/tests/components/samsungtv/test_trigger.py +++ b/tests/components/samsungtv/test_trigger.py @@ -30,7 +30,9 @@ async def test_turn_on_trigger_device_id( entity_id = f"{entity_domain}.fake" - device = device_registry.async_get_device(identifiers={(DOMAIN, "any")}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, "be9554b9-c9fb-41f4-8920-22da015376a4")} + ) assert device, repr(device_registry.devices) assert await async_setup_component( diff --git a/tests/components/schlage/test_select.py b/tests/components/schlage/test_select.py index 59ff065d449..c18ceb0ec8e 100644 --- a/tests/components/schlage/test_select.py +++ b/tests/components/schlage/test_select.py @@ -2,13 +2,17 @@ from unittest.mock import Mock +from pyschlage.lock import AUTO_LOCK_TIMES + +from homeassistant.components.schlage.const import DOMAIN from homeassistant.components.select import ( ATTR_OPTION, DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.translation import LOCALE_EN, async_get_translations from . import MockSchlageConfigEntry @@ -32,3 +36,12 @@ async def test_select( blocking=True, ) mock_lock.set_auto_lock_time.assert_called_once_with(30) + + +async def test_auto_lock_time_translations(hass: HomeAssistant) -> None: + """Test all auto_lock_time select options are translated.""" + prefix = f"component.{DOMAIN}.entity.{Platform.SELECT.value}.auto_lock_time.state." + translations = await async_get_translations(hass, LOCALE_EN, "entity", [DOMAIN]) + got_translation_states = {k for k in translations if k.startswith(prefix)} + want_translation_states = {f"{prefix}{t}" for t in AUTO_LOCK_TIMES} + assert want_translation_states == got_translation_states diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index dc9bf912c2d..c97e2cd3716 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -594,6 +594,8 @@ async def test_templates_with_yaml(hass: HomeAssistant) -> None: CONF_INDEX: 0, CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", CONF_AVAILABILITY: '{{ states("sensor.input1")=="on" }}', + CONF_ICON: 'mdi:o{{ "n" if states("sensor.input1")=="on" else "ff" }}', + CONF_PICTURE: 'o{{ "n" if states("sensor.input1")=="on" else "ff" }}.jpg', } ], } @@ -613,6 +615,8 @@ async def test_availability( state = hass.states.get("sensor.current_version") assert state.state == "2021.12.10" + assert state.attributes["icon"] == "mdi:on" + assert state.attributes["entity_picture"] == "on.jpg" hass.states.async_set("sensor.input1", "off") await hass.async_block_till_done() @@ -623,3 +627,93 @@ async def test_availability( state = hass.states.get("sensor.current_version") assert state.state == STATE_UNAVAILABLE + assert "icon" not in state.attributes + assert "entity_picture" not in state.attributes + + +async def test_template_render_with_availability_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test availability template render with syntax errors.""" + config = { + DOMAIN: [ + return_integration_config( + sensors=[ + { + "select": ".current-version h1", + "name": "Current version", + "unique_id": "ha_version_unique_id", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_AVAILABILITY: "{{ what_the_heck == 2 }}", + } + ] + ) + ] + } + + mocker = MockRestData("test_scrape_sensor") + with patch( + "homeassistant.components.rest.RestData", + return_value=mocker, + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.current_version") + assert state.state == "2021.12.10" + + assert ( + "Error rendering availability template for sensor.current_version: UndefinedError: 'what_the_heck' is undefined" + in caplog.text + ) + + +async def test_availability_blocks_value_template( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for sensor.current_version: 'x' is undefined" + config = { + DOMAIN: [ + return_integration_config( + sensors=[ + { + "select": ".current-version h1", + "name": "Current version", + "unique_id": "ha_version_unique_id", + CONF_VALUE_TEMPLATE: "{{ x - 1 }}", + CONF_AVAILABILITY: '{{ states("sensor.input1")=="on" }}', + } + ] + ) + ] + } + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + + mocker = MockRestData("test_scrape_sensor") + with patch( + "homeassistant.components.rest.RestData", + return_value=mocker, + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + assert error not in caplog.text + + state = hass.states.get("sensor.current_version") + assert state + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set("sensor.input1", "on") + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=10), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/sensorpro/__init__.py b/tests/components/sensorpro/__init__.py index da40ff9a3f7..a63bdbe08dc 100644 --- a/tests/components/sensorpro/__init__.py +++ b/tests/components/sensorpro/__init__.py @@ -1,8 +1,48 @@ """Tests for the SensorPro integration.""" -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from uuid import UUID -NOT_SENSORPRO_SERVICE_INFO = BluetoothServiceInfo( +from bleak.backends.device import BLEDevice +from bluetooth_data_tools import monotonic_time_coarse + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak + + +def make_bluetooth_service_info( + name: str, + manufacturer_data: dict[int, bytes], + service_uuids: list[str], + address: str, + rssi: int, + service_data: dict[UUID, bytes], + source: str, + tx_power: int = 0, + raw: bytes | None = None, +) -> BluetoothServiceInfoBleak: + """Create a BluetoothServiceInfoBleak object for testing.""" + return BluetoothServiceInfoBleak( + name=name, + manufacturer_data=manufacturer_data, + service_uuids=service_uuids, + address=address, + rssi=rssi, + service_data=service_data, + source=source, + device=BLEDevice( + name=name, + address=address, + details={}, + rssi=rssi, + ), + time=monotonic_time_coarse(), + advertisement=None, + connectable=True, + tx_power=tx_power, + raw=raw, + ) + + +NOT_SENSORPRO_SERVICE_INFO = make_bluetooth_service_info( name="Not it", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -12,7 +52,7 @@ NOT_SENSORPRO_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -SENSORPRO_SERVICE_INFO = BluetoothServiceInfo( +SENSORPRO_SERVICE_INFO = make_bluetooth_service_info( name="T201", address="aa:bb:cc:dd:ee:ff", rssi=-60, diff --git a/tests/components/sensorpush/__init__.py b/tests/components/sensorpush/__init__.py index aae960970dd..88fb2072961 100644 --- a/tests/components/sensorpush/__init__.py +++ b/tests/components/sensorpush/__init__.py @@ -1,8 +1,48 @@ """Tests for the SensorPush integration.""" -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from uuid import UUID -NOT_SENSOR_PUSH_SERVICE_INFO = BluetoothServiceInfo( +from bleak.backends.device import BLEDevice +from bluetooth_data_tools import monotonic_time_coarse + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak + + +def make_bluetooth_service_info( + name: str, + manufacturer_data: dict[int, bytes], + service_uuids: list[str], + address: str, + rssi: int, + service_data: dict[UUID, bytes], + source: str, + tx_power: int = 0, + raw: bytes | None = None, +) -> BluetoothServiceInfoBleak: + """Create a BluetoothServiceInfoBleak object for testing.""" + return BluetoothServiceInfoBleak( + name=name, + manufacturer_data=manufacturer_data, + service_uuids=service_uuids, + address=address, + rssi=rssi, + service_data=service_data, + source=source, + device=BLEDevice( + name=name, + address=address, + details={}, + rssi=rssi, + ), + time=monotonic_time_coarse(), + advertisement=None, + connectable=True, + tx_power=tx_power, + raw=raw, + ) + + +NOT_SENSOR_PUSH_SERVICE_INFO = make_bluetooth_service_info( name="Not it", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -12,7 +52,7 @@ NOT_SENSOR_PUSH_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -HTW_SERVICE_INFO = BluetoothServiceInfo( +HTW_SERVICE_INFO = make_bluetooth_service_info( name="SensorPush HT.w 0CA1", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -22,7 +62,7 @@ HTW_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -HTPWX_SERVICE_INFO = BluetoothServiceInfo( +HTPWX_SERVICE_INFO = make_bluetooth_service_info( name="SensorPush HTP.xw F4D", address="4125DDBA-2774-4851-9889-6AADDD4CAC3D", rssi=-56, @@ -33,7 +73,7 @@ HTPWX_SERVICE_INFO = BluetoothServiceInfo( ) -HTPWX_EMPTY_SERVICE_INFO = BluetoothServiceInfo( +HTPWX_EMPTY_SERVICE_INFO = make_bluetooth_service_info( name="SensorPush HTP.xw F4D", address="4125DDBA-2774-4851-9889-6AADDD4CAC3D", rssi=-56, diff --git a/tests/components/seventeentrack/test_sensor.py b/tests/components/seventeentrack/test_sensor.py index 5367fabba9e..11ed9904eae 100644 --- a/tests/components/seventeentrack/test_sensor.py +++ b/tests/components/seventeentrack/test_sensor.py @@ -4,20 +4,12 @@ from __future__ import annotations from unittest.mock import AsyncMock -from freezegun.api import FrozenDateTimeFactory from pyseventeentrack.errors import SeventeenTrackError from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from . import goto_future, init_integration -from .conftest import ( - DEFAULT_SUMMARY, - DEFAULT_SUMMARY_LENGTH, - NEW_SUMMARY_DATA, - VALID_PLATFORM_CONFIG_FULL, - get_package, -) +from . import init_integration +from .conftest import DEFAULT_SUMMARY, get_package from tests.common import MockConfigEntry @@ -78,38 +70,6 @@ async def test_package_error( assert hass.states.get("sensor.17track_package_friendly_name_1") is None -async def test_summary_correctly_updated( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure summary entities are not duplicated.""" - package = get_package(status=30) - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - await init_integration(hass, mock_config_entry) - - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH - - state_ready_picked = hass.states.get("sensor.17track_ready_to_be_picked_up") - assert state_ready_picked is not None - assert len(state_ready_picked.attributes["packages"]) == 1 - - mock_seventeentrack.return_value.profile.packages.return_value = [] - mock_seventeentrack.return_value.profile.summary.return_value = NEW_SUMMARY_DATA - - await goto_future(hass, freezer) - - assert len(hass.states.async_entity_ids()) == len(NEW_SUMMARY_DATA) - for state in hass.states.async_all(): - assert state.state == "1" - - state_ready_picked = hass.states.get("sensor.17track_ready_to_be_picked_up") - assert state_ready_picked is not None - assert len(state_ready_picked.attributes["packages"]) == 0 - - async def test_summary_error( hass: HomeAssistant, mock_seventeentrack: AsyncMock, @@ -129,13 +89,3 @@ async def test_summary_error( assert ( hass.states.get("sensor.seventeentrack_packages_ready_to_be_picked_up") is None ) - - -async def test_non_valid_platform_config( - hass: HomeAssistant, mock_seventeentrack: AsyncMock -) -> None: - """Test if login fails.""" - mock_seventeentrack.return_value.profile.login.return_value = False - assert await async_setup_component(hass, "sensor", VALID_PLATFORM_CONFIG_FULL) - await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == 0 diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index 56745c8be8e..3ad7395caad 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -79,7 +79,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -111,7 +113,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -591,6 +595,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'SFR Box Voltage', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -604,6 +609,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'SFR Box Temperature', + 'state_class': , 'unit_of_measurement': , }), 'context': , diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 2a386a1628c..dd17fe34cc8 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -1,5 +1,6 @@ """Test configuration for Shelly.""" +from collections.abc import Generator from copy import deepcopy from unittest.mock import AsyncMock, Mock, PropertyMock, patch @@ -497,6 +498,8 @@ def _mock_rpc_device(version: str | None = None): } ), xmod_info={}, + zigbee_enabled=False, + ip_address="10.10.10.10", ) type(device).name = PropertyMock(return_value="Test name") return device @@ -690,3 +693,21 @@ async def mock_sleepy_rpc_device(): rpc_device_mock.return_value.mock_initialized = Mock(side_effect=initialized) yield rpc_device_mock.return_value + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.shelly.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_setup() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.shelly.async_setup", return_value=True + ) as mock_setup: + yield mock_setup diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index b2135fb38af..81914bb6a90 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -5,12 +5,11 @@ from unittest.mock import AsyncMock, Mock, PropertyMock from aioshelly.const import ( BLU_TRV_IDENTIFIER, - BLU_TRV_TIMEOUT, MODEL_BLU_GATEWAY_G3, MODEL_VALVE, MODEL_WALL_DISPLAY, ) -from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest from syrupy import SnapshotAssertion @@ -799,15 +798,7 @@ async def test_blu_trv_climate_set_temperature( ) 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}, - }, - BLU_TRV_TIMEOUT, - ) + mock_blu_trv.blu_trv_set_target_temperature.assert_called_once_with(200, 28.0) assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_TEMPERATURE] == 28 @@ -857,3 +848,66 @@ async def test_blu_trv_climate_hvac_action( assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for climate.trv_name of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for climate.trv_name of Test name", + ), + ], +) +async def test_blu_trv_set_target_temp_exc( + hass: HomeAssistant, + mock_blu_trv: Mock, + exception: Exception, + error: str, +) -> None: + """BLU TRV target temperature setting test with excepton.""" + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.blu_trv_set_target_temperature.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.trv_name", ATTR_TEMPERATURE: 28}, + blocking=True, + ) + + +async def test_blu_trv_set_target_temp_auth_error( + hass: HomeAssistant, + mock_blu_trv: Mock, +) -> None: + """BLU TRV target temperature setting test with authentication error.""" + entry = await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.blu_trv_set_target_temperature.side_effect = InvalidAuthError + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.trv_name", ATTR_TEMPERATURE: 28}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 60883ebf5bd..93893035a3e 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -82,6 +82,8 @@ async def test_form( port: int, mock_block_device: Mock, mock_rpc_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -101,23 +103,15 @@ async def test_form( "port": port, }, ), - patch( - "homeassistant.components.shelly.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.shelly.async_setup_entry", - return_value=True, - ) as mock_setup_entry, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "1.1.1.1", CONF_PORT: port}, ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Test name" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { CONF_HOST: "1.1.1.1", CONF_PORT: port, CONF_MODEL: model, @@ -131,26 +125,19 @@ async def test_form( async def test_user_flow_overrides_existing_discovery( hass: HomeAssistant, mock_rpc_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, ) -> None: """Test setting up from the user flow when the devices is already discovered.""" - with ( - patch( - "homeassistant.components.shelly.config_flow.get_info", - return_value={ - "mac": "AABBCCDDEEFF", - "model": MODEL_PLUS_2PM, - "auth": False, - "gen": 2, - "port": 80, - }, - ), - patch( - "homeassistant.components.shelly.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.shelly.async_setup_entry", - return_value=True, - ) as mock_setup_entry, + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={ + "mac": "AABBCCDDEEFF", + "model": MODEL_PLUS_2PM, + "auth": False, + "gen": 2, + "port": 80, + }, ): discovery_result = await hass.config_entries.flow.async_init( DOMAIN, @@ -172,22 +159,21 @@ async def test_user_flow_overrides_existing_discovery( ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Test name" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { CONF_HOST: "1.1.1.1", CONF_PORT: 80, CONF_MODEL: MODEL_PLUS_2PM, CONF_SLEEP_PERIOD: 0, CONF_GEN: 2, } - assert result2["context"]["unique_id"] == "AABBCCDDEEFF" + assert result["context"]["unique_id"] == "AABBCCDDEEFF" assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -198,6 +184,8 @@ async def test_user_flow_overrides_existing_discovery( async def test_form_gen1_custom_port( hass: HomeAssistant, mock_block_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -216,13 +204,35 @@ async def test_form_gen1_custom_port( side_effect=CustomPortNotSupported, ), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1", "port": "1100"}, + {CONF_HOST: "1.1.1.1", CONF_PORT: "1100"}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"]["base"] == "custom_port_not_supported" + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "custom_port_not_supported" + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "type": MODEL_1, "gen": 1}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: DEFAULT_HTTP_PORT}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: MODEL_1, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 1, + } + assert result["context"]["unique_id"] == "test-mac" + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.parametrize( @@ -256,6 +266,8 @@ async def test_form_auth( username: str, mock_block_device: Mock, mock_rpc_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, ) -> None: """Test manual configuration if auth is required.""" result = await hass.config_entries.flow.async_init( @@ -268,31 +280,21 @@ async def test_form_auth( "homeassistant.components.shelly.config_flow.get_info", return_value={"mac": "test-mac", "type": MODEL_1, "auth": True, "gen": gen}, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "1.1.1.1"}, ) - assert result2["type"] is FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.shelly.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.shelly.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], user_input - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Test name" - assert result3["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { CONF_HOST: "1.1.1.1", CONF_PORT: DEFAULT_HTTP_PORT, CONF_MODEL: model, @@ -314,7 +316,12 @@ async def test_form_auth( ], ) async def test_form_errors_get_info( - hass: HomeAssistant, exc: Exception, base_error: str + hass: HomeAssistant, + mock_block_device: Mock, + mock_setup: AsyncMock, + mock_setup_entry: AsyncMock, + exc: Exception, + base_error: str, ) -> None: """Test we handle errors.""" result = await hass.config_entries.flow.async_init( @@ -322,13 +329,35 @@ async def test_form_errors_get_info( ) with patch("homeassistant.components.shelly.config_flow.get_info", side_effect=exc): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "1.1.1.1"}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": base_error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": base_error} + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "type": MODEL_1, "gen": 1}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: MODEL_1, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 1, + } + assert result["context"]["unique_id"] == "test-mac" + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 async def test_form_missing_model_key( @@ -343,13 +372,13 @@ async def test_form_missing_model_key( "homeassistant.components.shelly.config_flow.get_info", return_value={"mac": "test-mac", "auth": False, "gen": "2"}, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "1.1.1.1"}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "firmware_not_fully_provisioned"} + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "firmware_not_fully_provisioned" async def test_form_missing_model_key_auth_enabled( @@ -366,20 +395,20 @@ async def test_form_missing_model_key_auth_enabled( "homeassistant.components.shelly.config_flow.get_info", return_value={"mac": "test-mac", "auth": True, "gen": 2}, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "1.1.1.1"}, ) - assert result2["type"] is FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {} monkeypatch.setattr(mock_rpc_device, "shelly", {"gen": 2}) - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {CONF_PASSWORD: "1234"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "1234"} ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == {"base": "firmware_not_fully_provisioned"} + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "firmware_not_fully_provisioned" async def test_form_missing_model_key_zeroconf( @@ -398,15 +427,9 @@ async def test_form_missing_model_key_zeroconf( data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "firmware_not_fully_provisioned"} - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "firmware_not_fully_provisioned"} + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "firmware_not_fully_provisioned" @pytest.mark.parametrize( @@ -418,7 +441,12 @@ async def test_form_missing_model_key_zeroconf( ], ) async def test_form_errors_test_connection( - hass: HomeAssistant, exc: Exception, base_error: str + hass: HomeAssistant, + mock_block_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, + exc: Exception, + base_error: str, ) -> None: """Test we handle errors.""" result = await hass.config_entries.flow.async_init( @@ -434,13 +462,35 @@ async def test_form_errors_test_connection( "aioshelly.block_device.BlockDevice.create", new=AsyncMock(side_effect=exc) ), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "1.1.1.1"}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": base_error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": base_error} + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "auth": False}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: MODEL_1, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 1, + } + assert result["context"]["unique_id"] == "test-mac" + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 async def test_form_already_configured(hass: HomeAssistant) -> None: @@ -459,20 +509,23 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: "homeassistant.components.shelly.config_flow.get_info", return_value={"mac": "test-mac", "type": MODEL_1, "auth": False}, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "1.1.1.1"}, ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" # Test config entry got updated with latest IP assert entry.data[CONF_HOST] == "1.1.1.1" async def test_user_setup_ignored_device( - hass: HomeAssistant, mock_block_device: Mock + hass: HomeAssistant, + mock_block_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, ) -> None: """Test user can successfully setup an ignored device.""" @@ -488,25 +541,16 @@ async def test_user_setup_ignored_device( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch( - "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": MODEL_1, "auth": False}, - ), - patch( - "homeassistant.components.shelly.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.shelly.async_setup_entry", - return_value=True, - ) as mock_setup_entry, + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False}, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "1.1.1.1"}, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY # Test config entry got updated with latest IP assert entry.data[CONF_HOST] == "1.1.1.1" @@ -524,7 +568,12 @@ async def test_user_setup_ignored_device( ], ) async def test_form_auth_errors_test_connection_gen1( - hass: HomeAssistant, exc: Exception, base_error: str + hass: HomeAssistant, + mock_block_device: Mock, + mock_setup: AsyncMock, + mock_setup_entry: AsyncMock, + exc: Exception, + base_error: str, ) -> None: """Test we handle errors in Gen1 authenticated devices.""" result = await hass.config_entries.flow.async_init( @@ -535,21 +584,45 @@ async def test_form_auth_errors_test_connection_gen1( "homeassistant.components.shelly.config_flow.get_info", return_value={"mac": "test-mac", "auth": True}, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "1.1.1.1"}, ) with patch( "aioshelly.block_device.BlockDevice.create", - new=AsyncMock(side_effect=exc), + side_effect=exc, ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "test username", CONF_PASSWORD: "test password"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == {"base": base_error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": base_error} + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "auth": True}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "test username", CONF_PASSWORD: "test password"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: MODEL_1, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 1, + CONF_USERNAME: "test username", + CONF_PASSWORD: "test password", + } + assert result["context"]["unique_id"] == "test-mac" + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.parametrize( @@ -562,7 +635,12 @@ async def test_form_auth_errors_test_connection_gen1( ], ) async def test_form_auth_errors_test_connection_gen2( - hass: HomeAssistant, exc: Exception, base_error: str + hass: HomeAssistant, + mock_rpc_device: Mock, + mock_setup: AsyncMock, + mock_setup_entry: AsyncMock, + exc: Exception, + base_error: str, ) -> None: """Test we handle errors in Gen2 authenticated devices.""" result = await hass.config_entries.flow.async_init( @@ -573,20 +651,44 @@ async def test_form_auth_errors_test_connection_gen2( "homeassistant.components.shelly.config_flow.get_info", return_value={"mac": "test-mac", "auth": True, "gen": 2}, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "1.1.1.1"}, ) with patch( "aioshelly.rpc_device.RpcDevice.create", - new=AsyncMock(side_effect=exc), + side_effect=exc, ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {CONF_PASSWORD: "test password"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "test password"} ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == {"base": base_error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": base_error} + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "auth": True, "gen": 2}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "test password"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: "SNSW-002P16EU", + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 2, + CONF_USERNAME: "admin", + CONF_PASSWORD: "test password", + } + assert result["context"]["unique_id"] == "test-mac" + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.parametrize( @@ -616,6 +718,8 @@ async def test_zeroconf( get_info: dict[str, Any], mock_block_device: Mock, mock_rpc_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, ) -> None: """Test we get the form.""" @@ -636,24 +740,15 @@ async def test_zeroconf( ) assert context["title_placeholders"]["name"] == "shelly1pm-12345" assert context["confirm_only"] is True - with ( - patch( - "homeassistant.components.shelly.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.shelly.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Test name" - assert result2["data"] == { + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { CONF_HOST: "1.1.1.1", CONF_MODEL: model, CONF_SLEEP_PERIOD: 0, @@ -664,7 +759,11 @@ async def test_zeroconf( async def test_zeroconf_sleeping_device( - hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, ) -> None: """Test sleeping device configuration via zeroconf.""" monkeypatch.setitem( @@ -694,24 +793,15 @@ async def test_zeroconf_sleeping_device( if flow["flow_id"] == result["flow_id"] ) assert context["title_placeholders"]["name"] == "shelly1pm-12345" - with ( - patch( - "homeassistant.components.shelly.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.shelly.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Test name" - assert result2["data"] == { + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { CONF_HOST: "1.1.1.1", CONF_MODEL: MODEL_1, CONF_SLEEP_PERIOD: 600, @@ -743,8 +833,9 @@ async def test_zeroconf_sleeping_device_error(hass: HomeAssistant) -> None: data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" async def test_options_flow_abort_setup_retry( @@ -779,6 +870,19 @@ async def test_options_flow_abort_no_scripts_support( assert result["reason"] == "no_scripts_support" +async def test_options_flow_abort_zigbee_enabled( + hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test ble options abort if Zigbee is enabled for the device.""" + monkeypatch.setattr(mock_rpc_device, "zigbee_enabled", True) + entry = await init_integration(hass, 4) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "zigbee_enabled" + + async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -796,8 +900,9 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" # Test config entry got updated with latest IP assert entry.data[CONF_HOST] == "1.1.1.1" @@ -823,8 +928,9 @@ async def test_zeroconf_ignored(hass: HomeAssistant) -> None: data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" async def test_zeroconf_with_wifi_ap_ip(hass: HomeAssistant) -> None: @@ -846,8 +952,9 @@ async def test_zeroconf_with_wifi_ap_ip(hass: HomeAssistant) -> None: ), context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" # Test config entry was not updated with the wifi ap ip assert entry.data[CONF_HOST] == "2.2.2.2" @@ -864,12 +971,16 @@ async def test_zeroconf_cannot_connect(hass: HomeAssistant) -> None: data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" async def test_zeroconf_require_auth( - hass: HomeAssistant, mock_block_device: Mock + hass: HomeAssistant, + mock_block_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, ) -> None: """Test zeroconf if auth is required.""" @@ -882,27 +993,18 @@ async def test_zeroconf_require_auth( data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - with ( - patch( - "homeassistant.components.shelly.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.shelly.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_USERNAME: "test username", CONF_PASSWORD: "test password"}, - ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Test name" - assert result2["data"] == { + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "test username", CONF_PASSWORD: "test password"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { CONF_HOST: "1.1.1.1", CONF_PORT: DEFAULT_HTTP_PORT, CONF_MODEL: MODEL_1, @@ -951,8 +1053,8 @@ async def test_reauth_successful( user_input=user_input, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" @pytest.mark.parametrize( @@ -1008,8 +1110,8 @@ async def test_reauth_unsuccessful( user_input=user_input, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == abort_reason + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == abort_reason async def test_reauth_get_info_error(hass: HomeAssistant) -> None: @@ -1031,8 +1133,8 @@ async def test_reauth_get_info_error(hass: HomeAssistant) -> None: user_input={CONF_PASSWORD: "test2 password"}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_unsuccessful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_unsuccessful" async def test_options_flow_disabled_gen_1( @@ -1112,7 +1214,6 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device: Mock) -> N CONF_BLE_SCANNER_MODE: BLEScannerMode.DISABLED, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_BLE_SCANNER_MODE] is BLEScannerMode.DISABLED @@ -1128,7 +1229,6 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device: Mock) -> N CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_BLE_SCANNER_MODE] is BLEScannerMode.ACTIVE @@ -1144,7 +1244,6 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device: Mock) -> N CONF_BLE_SCANNER_MODE: BLEScannerMode.PASSIVE, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_BLE_SCANNER_MODE] is BLEScannerMode.PASSIVE @@ -1180,8 +1279,9 @@ async def test_zeroconf_already_configured_triggers_refresh_mac_in_name( data=DISCOVERY_INFO_WITH_MAC, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" monkeypatch.setattr(mock_rpc_device, "connected", False) mock_rpc_device.mock_disconnected() @@ -1220,8 +1320,9 @@ async def test_zeroconf_already_configured_triggers_refresh( data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" monkeypatch.setattr(mock_rpc_device, "connected", False) mock_rpc_device.mock_disconnected() @@ -1270,8 +1371,9 @@ async def test_zeroconf_sleeping_device_not_triggers_refresh( data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" monkeypatch.setattr(mock_rpc_device, "connected", False) mock_rpc_device.mock_disconnected() @@ -1324,8 +1426,9 @@ async def test_zeroconf_sleeping_device_attempts_configure( data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_rpc_device.update_outbound_websocket.mock_calls == [] @@ -1389,8 +1492,9 @@ async def test_zeroconf_sleeping_device_attempts_configure_ws_disabled( data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_rpc_device.update_outbound_websocket.mock_calls == [] @@ -1454,8 +1558,9 @@ async def test_zeroconf_sleeping_device_attempts_configure_no_url_available( data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_rpc_device.update_outbound_websocket.mock_calls == [] @@ -1500,8 +1605,8 @@ async def test_sleeping_device_gen2_with_new_firmware( result["flow_id"], {CONF_HOST: "1.1.1.1"}, ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { CONF_HOST: "1.1.1.1", CONF_PORT: DEFAULT_HTTP_PORT, @@ -1615,6 +1720,19 @@ async def test_reconfigure_with_exception( assert result["errors"] == {"base": base_error} + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False, "gen": 2}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "10.10.10.10", CONF_PORT: 99}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == {CONF_HOST: "10.10.10.10", CONF_PORT: 99, CONF_GEN: 2} + async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: """Test zeroconf discovery rejects ipv6.""" diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index f89bec8853a..cf7f82014a0 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -853,17 +853,28 @@ async def test_rpc_update_entry_fw_ver( assert device.sw_version == "99.0.0" -@pytest.mark.parametrize(("supports_scripts"), [True, False]) +@pytest.mark.parametrize( + ("supports_scripts", "zigbee_enabled", "result"), + [ + (True, False, True), + (True, True, False), + (False, True, False), + (False, False, False), + ], +) async def test_rpc_runs_connected_events_when_initialized( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, supports_scripts: bool, + zigbee_enabled: bool, + result: bool, ) -> None: """Test RPC runs connected events when initialized.""" monkeypatch.setattr( mock_rpc_device, "supports_scripts", AsyncMock(return_value=supports_scripts) ) + monkeypatch.setattr(mock_rpc_device, "zigbee_enabled", zigbee_enabled) monkeypatch.setattr(mock_rpc_device, "initialized", False) await init_integration(hass, 2) @@ -876,7 +887,8 @@ async def test_rpc_runs_connected_events_when_initialized( assert call.supports_scripts() in mock_rpc_device.mock_calls # BLE script list is called during connected events if device supports scripts - assert bool(call.script_list() in mock_rpc_device.mock_calls) == supports_scripts + # and Zigbee is disabled + assert bool(call.script_list() in mock_rpc_device.mock_calls) == result async def test_rpc_sleeping_device_unload_ignore_ble_scanner( diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 129aa812580..4cf49a2dab8 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -16,6 +16,8 @@ from aioshelly.rpc_device.utils import bluetooth_mac_from_primary_mac import pytest from homeassistant.components.shelly.const import ( + BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID, + BLE_SCANNER_MIN_FIRMWARE, BLOCK_EXPECTED_SLEEP_PERIOD, BLOCK_WRONG_SLEEP_PERIOD, CONF_BLE_SCANNER_MODE, @@ -38,7 +40,7 @@ from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import DeviceRegistry, format_mac from homeassistant.setup import async_setup_component -from . import init_integration, mutate_rpc_device_status +from . import MOCK_MAC, init_integration, mutate_rpc_device_status async def test_custom_coap_port( @@ -579,3 +581,27 @@ async def test_device_script_getcode_error( entry = await init_integration(hass, 2) assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_ble_scanner_unsupported_firmware_fixed( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + issue_registry: ir.IssueRegistry, +) -> None: + """Test device init with unsupported firmware.""" + issue_id = BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=MOCK_MAC) + entry = await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + monkeypatch.setitem(mock_rpc_device.shelly, "ver", BLE_SCANNER_MIN_FIRMWARE) + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 41002917d86..8589d643b2b 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -3,8 +3,8 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock -from aioshelly.const import BLU_TRV_TIMEOUT, MODEL_BLU_GATEWAY_G3 -from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError +from aioshelly.const import MODEL_BLU_GATEWAY_G3 +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest from syrupy import SnapshotAssertion @@ -334,6 +334,8 @@ async def test_rpc_device_virtual_number( blocking=True, ) mock_rpc_device.mock_update() + mock_rpc_device.number_set.assert_called_once_with(203, 56.7) + assert (state := hass.states.get(entity_id)) assert state.state == "56.7" @@ -446,15 +448,7 @@ async def test_blu_trv_ext_temp_set_value( blocking=True, ) mock_blu_trv.mock_update() - mock_blu_trv.call_rpc.assert_called_once_with( - "BluTRV.Call", - { - "id": 200, - "method": "Trv.SetExternalTemperature", - "params": {"id": 0, "t_C": 22.2}, - }, - BLU_TRV_TIMEOUT, - ) + mock_blu_trv.blu_trv_set_external_temperature.assert_called_once_with(200, 22.2) assert (state := hass.states.get(entity_id)) assert state.state == "22.2" @@ -487,17 +481,77 @@ async def test_blu_trv_valve_pos_set_value( blocking=True, ) mock_blu_trv.mock_update() - mock_blu_trv.call_rpc.assert_called_once_with( - "BluTRV.Call", - { - "id": 200, - "method": "Trv.SetPosition", - "params": {"id": 0, "pos": 20}, - }, - BLU_TRV_TIMEOUT, - ) - # device only accepts int for 'pos' value - assert isinstance(mock_blu_trv.call_rpc.call_args[0][1]["params"]["pos"], int) + mock_blu_trv.blu_trv_set_valve_position.assert_called_once_with(200, 20.0) assert (state := hass.states.get(entity_id)) assert state.state == "20" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for number.trv_name_external_temperature of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for number.trv_name_external_temperature of Test name", + ), + ], +) +async def test_blu_trv_number_exc( + hass: HomeAssistant, + mock_blu_trv: Mock, + exception: Exception, + error: str, +) -> None: + """Test RPC/BLU TRV number with exception.""" + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.blu_trv_set_external_temperature.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.trv_name_external_temperature", + ATTR_VALUE: 20.0, + }, + blocking=True, + ) + + +async def test_blu_trv_number_reauth_error( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC/BLU TRV number with authentication error.""" + entry = await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.blu_trv_set_external_temperature.side_effect = InvalidAuthError + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.trv_name_external_temperature", + ATTR_VALUE: 20.0, + }, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id diff --git a/tests/components/shelly/test_repairs.py b/tests/components/shelly/test_repairs.py new file mode 100644 index 00000000000..f68d2f82f1b --- /dev/null +++ b/tests/components/shelly/test_repairs.py @@ -0,0 +1,131 @@ +"""Test repairs handling for Shelly.""" + +from unittest.mock import Mock + +from aioshelly.exceptions import DeviceConnectionError, RpcCallError +import pytest + +from homeassistant.components.shelly.const import ( + BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID, + CONF_BLE_SCANNER_MODE, + DOMAIN, + BLEScannerMode, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from . import MOCK_MAC, init_integration + +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator + + +async def test_ble_scanner_unsupported_firmware_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issues handling for BLE scanner with unsupported firmware.""" + issue_id = BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "create_entry" + assert mock_rpc_device.trigger_ota_update.call_count == 1 + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +async def test_unsupported_firmware_issue_update_not_available( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issues handling when firmware update is not available.""" + issue_id = BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + monkeypatch.setitem(mock_rpc_device.status, "sys", {"available_updates": {}}) + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "abort" + assert result["reason"] == "update_not_available" + assert mock_rpc_device.trigger_ota_update.call_count == 0 + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + +@pytest.mark.parametrize( + "exception", [DeviceConnectionError, RpcCallError(999, "Unknown error")] +) +async def test_unsupported_firmware_issue_exc( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + issue_registry: ir.IssueRegistry, + exception: Exception, +) -> None: + """Test repair issues handling when OTA update ends with an exception.""" + issue_id = BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + mock_rpc_device.trigger_ota_update.side_effect = exception + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + assert mock_rpc_device.trigger_ota_update.call_count == 1 + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 diff --git a/tests/components/shelly/test_select.py b/tests/components/shelly/test_select.py index 39e426baa58..bb68edd1961 100644 --- a/tests/components/shelly/test_select.py +++ b/tests/components/shelly/test_select.py @@ -3,6 +3,7 @@ from copy import deepcopy from unittest.mock import Mock +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest from homeassistant.components.select import ( @@ -11,8 +12,11 @@ from homeassistant.components.select import ( DOMAIN as SELECT_PLATFORM, SERVICE_SELECT_OPTION, ) +from homeassistant.components.shelly.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -81,7 +85,7 @@ async def test_rpc_device_virtual_enum( blocking=True, ) # 'Title 1' corresponds to 'option 1' - assert mock_rpc_device.call_rpc.call_args[0][1] == {"id": 203, "value": "option 1"} + mock_rpc_device.enum_set.assert_called_once_with(203, "option 1") mock_rpc_device.mock_update() assert (state := hass.states.get(entity_id)) @@ -149,3 +153,108 @@ async def test_rpc_remove_virtual_enum_when_orphaned( await hass.async_block_till_done() assert entity_registry.async_get(entity_id) is None + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for select.test_name_enum_203 of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for select.test_name_enum_203 of Test name", + ), + ], +) +async def test_select_set_exc( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + exception: Exception, + error: str, +) -> None: + """Test select setting with exception.""" + config = deepcopy(mock_rpc_device.config) + config["enum:203"] = { + "name": None, + "options": ["option 1", "option 2", "option 3"], + "meta": { + "ui": { + "view": "dropdown", + "titles": {"option 1": "Title 1", "option 2": None}, + } + }, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["enum:203"] = {"value": "option 1"} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + + mock_rpc_device.enum_set.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + SELECT_PLATFORM, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"{SELECT_PLATFORM}.test_name_enum_203", + ATTR_OPTION: "option 2", + }, + blocking=True, + ) + + +async def test_select_set_reauth_error( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test select setting with authentication error.""" + config = deepcopy(mock_rpc_device.config) + config["enum:203"] = { + "name": None, + "options": ["option 1", "option 2", "option 3"], + "meta": { + "ui": { + "view": "dropdown", + "titles": {"option 1": "Title 1", "option 2": None}, + } + }, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["enum:203"] = {"value": "option 1"} + monkeypatch.setattr(mock_rpc_device, "status", status) + + entry = await init_integration(hass, 3) + + mock_rpc_device.enum_set.side_effect = InvalidAuthError + + await hass.services.async_call( + SELECT_PLATFORM, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"{SELECT_PLATFORM}.test_name_enum_203", + ATTR_OPTION: "option 2", + }, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id diff --git a/tests/components/shelly/test_text.py b/tests/components/shelly/test_text.py index a4812cc4160..165272313cb 100644 --- a/tests/components/shelly/test_text.py +++ b/tests/components/shelly/test_text.py @@ -3,15 +3,19 @@ from copy import deepcopy from unittest.mock import Mock +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest +from homeassistant.components.shelly.const import DOMAIN from homeassistant.components.text import ( ATTR_VALUE, DOMAIN as TEXT_PLATFORM, SERVICE_SET_VALUE, ) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -67,6 +71,7 @@ async def test_rpc_device_virtual_text( blocking=True, ) mock_rpc_device.mock_update() + mock_rpc_device.text_set.assert_called_once_with(203, "sed do eiusmod") assert (state := hass.states.get(entity_id)) assert state.state == "sed do eiusmod" @@ -127,3 +132,96 @@ async def test_rpc_remove_virtual_text_when_orphaned( await hass.async_block_till_done() assert entity_registry.async_get(entity_id) is None + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for text.test_name_text_203 of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for text.test_name_text_203 of Test name", + ), + ], +) +async def test_text_set_exc( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + exception: Exception, + error: str, +) -> None: + """Test text setting with exception.""" + config = deepcopy(mock_rpc_device.config) + config["text:203"] = { + "name": None, + "meta": {"ui": {"view": "field"}}, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["text:203"] = {"value": "lorem ipsum"} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + + mock_rpc_device.text_set.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + TEXT_PLATFORM, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"{TEXT_PLATFORM}.test_name_text_203", + ATTR_VALUE: "new value", + }, + blocking=True, + ) + + +async def test_text_set_reauth_error( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test text setting with authentication error.""" + config = deepcopy(mock_rpc_device.config) + config["text:203"] = { + "name": None, + "meta": {"ui": {"view": "field"}}, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["text:203"] = {"value": "lorem ipsum"} + monkeypatch.setattr(mock_rpc_device, "status", status) + + entry = await init_integration(hass, 3) + + mock_rpc_device.text_set.side_effect = InvalidAuthError + + await hass.services.async_call( + TEXT_PLATFORM, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"{TEXT_PLATFORM}.test_name_text_203", + ATTR_VALUE: "new value", + }, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id diff --git a/tests/components/sleepiq/test_init.py b/tests/components/sleepiq/test_init.py index 216d0e49b08..65e9e63a372 100644 --- a/tests/components/sleepiq/test_init.py +++ b/tests/components/sleepiq/test_init.py @@ -29,7 +29,12 @@ from .conftest import ( setup_platform, ) -from tests.common import MockConfigEntry, async_fire_time_changed, mock_registry +from tests.common import ( + MockConfigEntry, + RegistryEntryWithDefaults, + async_fire_time_changed, + mock_registry, +) ENTITY_IS_IN_BED = f"sensor.sleepnumber_{BED_ID}_{SLEEPER_L_NAME_LOWER}_{IS_IN_BED}" ENTITY_PRESSURE = f"sensor.sleepnumber_{BED_ID}_{SLEEPER_L_NAME_LOWER}_{PRESSURE}" @@ -103,19 +108,19 @@ async def test_unique_id_migration(hass: HomeAssistant, mock_asyncsleepiq) -> No mock_registry( hass, { - ENTITY_IS_IN_BED: er.RegistryEntry( + ENTITY_IS_IN_BED: RegistryEntryWithDefaults( entity_id=ENTITY_IS_IN_BED, unique_id=f"{BED_ID}_{SLEEPER_L_NAME}_{IS_IN_BED}", platform=DOMAIN, config_entry_id=mock_entry.entry_id, ), - ENTITY_PRESSURE: er.RegistryEntry( + ENTITY_PRESSURE: RegistryEntryWithDefaults( entity_id=ENTITY_PRESSURE, unique_id=f"{BED_ID}_{SLEEPER_L_NAME}_{PRESSURE}", platform=DOMAIN, config_entry_id=mock_entry.entry_id, ), - ENTITY_SLEEP_NUMBER: er.RegistryEntry( + ENTITY_SLEEP_NUMBER: RegistryEntryWithDefaults( entity_id=ENTITY_SLEEP_NUMBER, unique_id=f"{BED_ID}_{SLEEPER_L_NAME}_{SLEEP_NUMBER}", platform=DOMAIN, diff --git a/tests/components/sma/__init__.py b/tests/components/sma/__init__.py index 80837c718a9..4a9e462501e 100644 --- a/tests/components/sma/__init__.py +++ b/tests/components/sma/__init__.py @@ -1,7 +1,17 @@ """Tests for the sma integration.""" +import unittest from unittest.mock import patch +from homeassistant.components.sma.const import CONF_GROUP +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) + MOCK_DEVICE = { "manufacturer": "SMA", "name": "SMA Device Name", @@ -10,15 +20,33 @@ MOCK_DEVICE = { } MOCK_USER_INPUT = { - "host": "1.1.1.1", - "ssl": True, - "verify_ssl": False, - "group": "user", - "password": "password", + CONF_HOST: "1.1.1.1", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + CONF_GROUP: "user", + CONF_PASSWORD: "password", +} + +MOCK_DHCP_DISCOVERY_INPUT = { + # CONF_HOST: "1.1.1.2", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + CONF_GROUP: "user", + CONF_PASSWORD: "password", +} + +MOCK_DHCP_DISCOVERY = { + CONF_HOST: "1.1.1.1", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + CONF_GROUP: "user", + CONF_PASSWORD: "password", + CONF_MAC: "00:15:bb:00:ab:cd", } -def _patch_async_setup_entry(return_value=True): +def _patch_async_setup_entry(return_value=True) -> unittest.mock._patch: + """Patch async_setup_entry.""" return patch( "homeassistant.components.sma.async_setup_entry", return_value=return_value, diff --git a/tests/components/sma/conftest.py b/tests/components/sma/conftest.py index dd47a0f1055..2b4c157175b 100644 --- a/tests/components/sma/conftest.py +++ b/tests/components/sma/conftest.py @@ -17,9 +17,9 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_config_entry() -> MockConfigEntry: +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" - return MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, title=MOCK_DEVICE["name"], unique_id=str(MOCK_DEVICE["serial"]), @@ -27,6 +27,8 @@ def mock_config_entry() -> MockConfigEntry: source=config_entries.SOURCE_IMPORT, minor_version=2, ) + entry.add_to_hass(hass) + return entry @pytest.fixture diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index 93ac1783e09..5033462d0a6 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -7,13 +7,35 @@ from pysma.exceptions import ( SmaConnectionException, SmaReadException, ) +import pytest from homeassistant.components.sma.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from . import MOCK_DEVICE, MOCK_USER_INPUT, _patch_async_setup_entry +from . import ( + MOCK_DEVICE, + MOCK_DHCP_DISCOVERY, + MOCK_DHCP_DISCOVERY_INPUT, + MOCK_USER_INPUT, + _patch_async_setup_entry, +) + +from tests.conftest import MockConfigEntry + +DHCP_DISCOVERY = DhcpServiceInfo( + ip="1.1.1.1", + hostname="SMA123456", + macaddress="0015BB00abcd", +) + +DHCP_DISCOVERY_DUPLICATE = DhcpServiceInfo( + ip="1.1.1.1", + hostname="SMA123456789", + macaddress="0015BB00abcd", +) async def test_form(hass: HomeAssistant) -> None: @@ -43,14 +65,27 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("exception", "error"), + [ + (SmaConnectionException, "cannot_connect"), + (SmaAuthenticationException, "invalid_auth"), + (SmaReadException, "cannot_retrieve_device_info"), + (Exception, "unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, exception: Exception, error: str +) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) with ( - patch("pysma.SMA.new_session", side_effect=SmaConnectionException), + patch( + "homeassistant.components.sma.pysma.SMA.new_session", side_effect=exception + ), _patch_async_setup_entry() as mock_setup_entry, ): result = await hass.config_entries.flow.async_configure( @@ -59,83 +94,27 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": error} assert len(mock_setup_entry.mock_calls) == 0 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with ( - patch("pysma.SMA.new_session", side_effect=SmaAuthenticationException), - _patch_async_setup_entry() as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} - assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_form_cannot_retrieve_device_info(hass: HomeAssistant) -> None: - """Test we handle cannot retrieve device info error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with ( - patch("pysma.SMA.new_session", return_value=True), - patch("pysma.SMA.read", side_effect=SmaReadException), - _patch_async_setup_entry() as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_retrieve_device_info"} - assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_form_unexpected_exception(hass: HomeAssistant) -> None: - """Test we handle unexpected exception.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with ( - patch("pysma.SMA.new_session", side_effect=Exception), - _patch_async_setup_entry() as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "unknown"} - assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_form_already_configured(hass: HomeAssistant, mock_config_entry) -> None: +async def test_form_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test starting a flow by user when already configured.""" - mock_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) with ( - patch("pysma.SMA.new_session", return_value=True), - patch("pysma.SMA.device_info", return_value=MOCK_DEVICE), - patch("pysma.SMA.close_session", return_value=True), + patch("homeassistant.components.sma.pysma.SMA.new_session", return_value=True), + patch( + "homeassistant.components.sma.pysma.SMA.device_info", + return_value=MOCK_DEVICE, + ), + patch( + "homeassistant.components.sma.pysma.SMA.close_session", return_value=True + ), _patch_async_setup_entry() as mock_setup_entry, ): result = await hass.config_entries.flow.async_configure( @@ -146,3 +125,99 @@ async def test_form_already_configured(hass: HomeAssistant, mock_config_entry) - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_dhcp_discovery(hass: HomeAssistant) -> 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" + + with ( + patch("homeassistant.components.sma.pysma.SMA.new_session", return_value=True), + patch( + "homeassistant.components.sma.pysma.SMA.device_info", + return_value=MOCK_DEVICE, + ), + _patch_async_setup_entry() as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DHCP_DISCOVERY_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_DHCP_DISCOVERY["host"] + assert result["data"] == MOCK_DHCP_DISCOVERY + assert result["result"].unique_id == DHCP_DISCOVERY.hostname.replace("SMA", "") + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test starting a flow by dhcp when already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY_DUPLICATE + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (SmaConnectionException, "cannot_connect"), + (SmaAuthenticationException, "invalid_auth"), + (SmaReadException, "cannot_retrieve_device_info"), + (Exception, "unknown"), + ], +) +async def test_dhcp_exceptions( + hass: HomeAssistant, exception: Exception, error: str +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + with patch( + "homeassistant.components.sma.pysma.SMA.new_session", side_effect=exception + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DHCP_DISCOVERY_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + with ( + patch("homeassistant.components.sma.pysma.SMA.new_session", return_value=True), + patch( + "homeassistant.components.sma.pysma.SMA.device_info", + return_value=MOCK_DEVICE, + ), + patch( + "homeassistant.components.sma.pysma.SMA.close_session", return_value=True + ), + _patch_async_setup_entry(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DHCP_DISCOVERY_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_DHCP_DISCOVERY["host"] + assert result["data"] == MOCK_DHCP_DISCOVERY + assert result["result"].unique_id == DHCP_DISCOVERY.hostname.replace("SMA", "") diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index fce344b57a7..f316db7bef8 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -3,7 +3,8 @@ from typing import Any from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability, DeviceEvent +from pysmartthings import Attribute, Capability, DeviceEvent, DeviceHealthEvent +from pysmartthings.models import HealthStatus from syrupy import SnapshotAssertion from homeassistant.components.smartthings.const import MAIN @@ -78,3 +79,14 @@ async def trigger_update( if call[0][0] == device_id and call[0][2] == capability: call[0][3](event) await hass.async_block_till_done() + + +async def trigger_health_update( + hass: HomeAssistant, mock: AsyncMock, device_id: str, status: HealthStatus +) -> None: + """Trigger a health update.""" + event = DeviceHealthEvent("abc", "abc", status) + for call in mock.add_device_availability_event_listener.call_args_list: + if call[0][0] == device_id: + call[0][1](event) + await hass.async_block_till_done() diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index aa29a610620..b3a58b17637 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -5,6 +5,7 @@ import time from unittest.mock import AsyncMock, patch from pysmartthings import ( + DeviceHealth, DeviceResponse, DeviceStatus, LocationResponse, @@ -12,6 +13,7 @@ from pysmartthings import ( SceneResponse, Subscription, ) +from pysmartthings.models import HealthStatus import pytest from homeassistant.components.application_credentials import ( @@ -86,6 +88,9 @@ def mock_smartthings() -> Generator[AsyncMock]: client.create_subscription.return_value = Subscription.from_json( load_fixture("subscription.json", DOMAIN) ) + client.get_device_health.return_value = DeviceHealth.from_json( + load_fixture("device_health.json", DOMAIN) + ) yield client @@ -116,6 +121,8 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_wm_dw_000001", "da_wm_wd_000001", "da_wm_wd_000001_1", + "da_wm_wm_01011", + "da_wm_wm_100001", "da_wm_wm_000001", "da_wm_wm_000001_1", "da_wm_sc_000001", @@ -170,6 +177,13 @@ def devices(mock_smartthings: AsyncMock, device_fixture: str) -> Generator[Async return mock_smartthings +@pytest.fixture +def unavailable_device(devices: AsyncMock) -> AsyncMock: + """Mock an unavailable device.""" + devices.get_device_health.return_value.state = HealthStatus.OFFLINE + return devices + + @pytest.fixture def mock_config_entry(expires_at: int) -> MockConfigEntry: """Mock a config entry.""" diff --git a/tests/components/smartthings/fixtures/device_health.json b/tests/components/smartthings/fixtures/device_health.json new file mode 100644 index 00000000000..7ae42d6206e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_health.json @@ -0,0 +1,5 @@ +{ + "deviceId": "612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3", + "state": "ONLINE", + "lastUpdatedDate": "2025-04-28T11:43:31.600Z" +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wm_01011.json b/tests/components/smartthings/fixtures/device_status/da_wm_wm_01011.json new file mode 100644 index 00000000000..21949e100f7 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wm_01011.json @@ -0,0 +1,1791 @@ +{ + "components": { + "hca.main": { + "hca.washerMode": { + "mode": { + "value": "others", + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "supportedModes": { + "value": ["normal", "quickWash", "mix", "eco", "spinOnly"], + "timestamp": "2025-04-25T07:40:12.944Z" + } + } + }, + "main": { + "custom.dryerWrinklePrevent": { + "operatingState": { + "value": null + }, + "dryerWrinklePrevent": { + "value": null + } + }, + "samsungce.washerWaterLevel": { + "supportedWaterLevel": { + "value": null + }, + "waterLevel": { + "value": null + } + }, + "custom.washerWaterTemperature": { + "supportedWasherWaterTemperature": { + "value": ["none", "cold", "20", "30", "40", "60", "90"], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "washerWaterTemperature": { + "value": "40", + "timestamp": "2025-04-25T08:13:49.565Z" + } + }, + "samsungce.softenerAutoReplenishment": { + "regularSoftenerType": { + "value": "none", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularSoftenerAlarmEnabled": { + "value": false, + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularSoftenerInitialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularSoftenerRemainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularSoftenerDosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularSoftenerOrderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.autoDispenseSoftener": { + "remainingAmount": { + "value": null + }, + "amount": { + "value": null + }, + "supportedDensity": { + "value": null + }, + "density": { + "value": null + }, + "supportedAmount": { + "value": null + } + }, + "samsungce.autoDispenseDetergent": { + "remainingAmount": { + "value": "normal", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "amount": { + "value": "standard", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "supportedDensity": { + "value": ["normal", "high", "extraHigh"], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "density": { + "value": "high", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "supportedAmount": { + "value": ["none", "less", "standard", "extra"], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "availableTypes": { + "value": ["regularDetergent"], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "type": { + "value": "regularDetergent", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "supportedTypes": { + "value": null + }, + "recommendedAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.washerWaterValve": { + "waterValve": { + "value": null + }, + "supportedWaterValve": { + "value": null + } + }, + "washerOperatingState": { + "completionTime": { + "value": "2025-04-25T10:34:12Z", + "timestamp": "2025-04-25T07:49:12.761Z" + }, + "machineState": { + "value": "run", + "timestamp": "2025-04-25T07:49:28.858Z" + }, + "washerJobState": { + "value": "wash", + "timestamp": "2025-04-25T07:50:32.365Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "custom.washerAutoSoftener": { + "washerAutoSoftener": { + "value": null + } + }, + "samsungce.washerCycle": { + "cycleType": { + "value": "washingOnly", + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "supportedCycles": { + "value": [ + { + "cycle": "1C", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "2B", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "933F", + "default": "3", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8410", + "default": "40", + "options": ["40"] + } + } + }, + { + "cycle": "1B", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "847E", + "default": "40", + "options": ["cold", "20", "30", "40", "60", "90"] + } + } + }, + { + "cycle": "1E", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A53F", + "default": "1200", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200" + ] + }, + "rinseCycle": { + "raw": "933F", + "default": "3", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "1D", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "96", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A37F", + "default": "800", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "8F", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A57F", + "default": "1200", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8102", + "default": "cold", + "options": ["cold"] + } + } + }, + { + "cycle": "25", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A57F", + "default": "1200", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "933F", + "default": "3", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "843E", + "default": "40", + "options": ["cold", "20", "30", "40", "60"] + } + } + }, + { + "cycle": "26", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A207", + "default": "400", + "options": ["rinseHold", "noSpin", "400"] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "831E", + "default": "30", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "33", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "933F", + "default": "3", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "857E", + "default": "60", + "options": ["cold", "20", "30", "40", "60", "90"] + } + } + }, + { + "cycle": "24", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "930F", + "default": "3", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "32", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A37F", + "default": "800", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "833E", + "default": "30", + "options": ["cold", "20", "30", "40", "60"] + } + } + }, + { + "cycle": "20", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "943F", + "default": "4", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "857E", + "default": "60", + "options": ["cold", "20", "30", "40", "60", "90"] + } + } + }, + { + "cycle": "22", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "23", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A57F", + "default": "1200", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "930F", + "default": "3", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "831E", + "default": "30", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "2F", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "933F", + "default": "3", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "831E", + "default": "30", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "21", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A57F", + "default": "1200", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "943F", + "default": "4", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "2A", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "933F", + "default": "3", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "831E", + "default": "30", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "2E", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "943F", + "default": "4", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "867E", + "default": "90", + "options": ["cold", "20", "30", "40", "60", "90"] + } + } + }, + { + "cycle": "2D", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "30", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "843E", + "default": "40", + "options": ["cold", "20", "30", "40", "60"] + } + } + }, + { + "cycle": "29", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "waterTemperature": { + "raw": "8520", + "default": "70", + "options": ["70"] + }, + "spinLevel": { + "raw": "A520", + "default": "1200", + "options": ["1200"] + }, + "rinseCycle": { + "raw": "9204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "27", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "913F", + "default": "1", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "28", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A67E", + "default": "1400", + "options": ["noSpin", "400", "800", "1000", "1200", "1400"] + }, + "rinseCycle": { + "raw": "9000", + "default": "0", + "options": [] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + } + ], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "washerCycle": { + "value": "Table_02_Course_1C", + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "referenceTable": { + "value": { + "id": "Table_02" + }, + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "specializedFunctionClassification": { + "value": 4, + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.waterConsumptionReport": { + "waterConsumption": { + "value": { + "cumulativeAmount": 1642200, + "delta": 0, + "start": "2025-04-25T08:28:43Z", + "end": "2025-04-25T08:43:46Z" + }, + "timestamp": "2025-04-25T08:43:46.404Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "DA_WM_TP1_21_COMMON_30240927", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "di": { + "value": "b854ca5f-dc54-140d-6349-758b4d973c41", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "n": { + "value": "[washer] Samsung", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "mnmo": { + "value": "DA_WM_TP1_21_COMMON|20374641|20010002001811364AA30277008E0000", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "vid": { + "value": "DA-WM-WM-01011", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "pi": { + "value": "b854ca5f-dc54-140d-6349-758b4d973c41", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-04-25T08:13:43.103Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": null + }, + "supportedDryerDryLevel": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.autoDispenseSoftener", + "samsungce.energyPlanner", + "logTrigger", + "sec.smartthingsHub", + "samsungce.washerFreezePrevent", + "custom.dryerDryLevel", + "samsungce.dryerDryingTime", + "custom.dryerWrinklePrevent", + "custom.washerSoilLevel", + "samsungce.washerWaterLevel", + "samsungce.washerWaterValve", + "samsungce.washerWashingTime", + "custom.washerAutoDetergent", + "custom.washerAutoSoftener" + ], + "timestamp": "2025-04-25T08:07:14.496Z" + } + }, + "logTrigger": { + "logState": { + "value": null + }, + "logRequestState": { + "value": null + }, + "logInfo": { + "value": null + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 25020102, + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "minVersion": { + "value": "3.0", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "WFC", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "protocolType": { + "value": "ble_ocf", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "tsId": { + "value": "DA01", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.washerOperatingState": { + "washerJobState": { + "value": "wash", + "timestamp": "2025-04-25T07:50:32.365Z" + }, + "operatingState": { + "value": "running", + "timestamp": "2025-04-25T07:49:28.858Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "wash", + "timeInMin": 133 + }, + { + "jobName": "rinse", + "timeInMin": 19 + }, + { + "jobName": "spin", + "timeInMin": 12 + } + ], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "scheduledPhases": { + "value": [ + { + "phaseName": "wash", + "timeInMin": 133 + }, + { + "phaseName": "rinse", + "timeInMin": 19 + }, + { + "phaseName": "spin", + "timeInMin": 12 + } + ], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "progress": { + "value": 40, + "unit": "%", + "timestamp": "2025-04-25T08:54:30.139Z" + }, + "remainingTimeStr": { + "value": "01:40", + "timestamp": "2025-04-25T08:54:30.139Z" + }, + "washerJobPhase": { + "value": "wash", + "timestamp": "2025-04-25T07:50:32.365Z" + }, + "operationTime": { + "value": 165, + "unit": "min", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "remainingTime": { + "value": 100, + "unit": "min", + "timestamp": "2025-04-25T08:54:30.139Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2025-04-25T07:40:12.942Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 26800, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-04-25T08:28:43Z", + "end": "2025-04-25T08:43:46Z" + }, + "timestamp": "2025-04-25T08:43:46.217Z" + } + }, + "samsungce.softenerOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "custom.washerSoilLevel": { + "supportedWasherSoilLevel": { + "value": null + }, + "washerSoilLevel": { + "value": null + } + }, + "samsungce.washerBubbleSoak": { + "status": { + "value": "off", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.washerLabelScanCyclePreset": { + "presets": { + "value": { + "FB": {} + }, + "timestamp": "2025-04-25T07:40:12.942Z" + } + }, + "execute": { + "data": { + "value": null + } + }, + "samsungce.softenerState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "softenerType": { + "value": "none", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.energyPlanner": { + "data": { + "value": null + }, + "plan": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G"], + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "protocolType": { + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-04-25T07:40:12.942Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "02986A240927(A159)", + "description": "DA_WM_TP1_21_COMMON|20374641|20010002001811364AA30277008E0000" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "03746A24030804,03724A24031617", + "description": "Firmware_1_DB_20374641240308040FFFFF203724412403161704FFFF(01672037464120372441_30000000)(FileDown:0)(Type:0)" + }, + { + "id": "2", + "swType": "Firmware", + "versionNumber": "03628B24030602,FFFFFFFFFFFFFF", + "description": "Firmware_2_DB_2036284224030602042FFFFFFFFFFFFFFFFFFFFFFFFE(016720362842FFFFFFFF_30000000)(FileDown:0)(Type:0)" + } + ], + "timestamp": "2025-04-25T08:13:47.726Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-04-25T07:48:54.109Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": "1C", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "referenceTable": { + "value": { + "id": "Table_02" + }, + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "supportedCourses": { + "value": [ + "1C", + "2B", + "1B", + "1E", + "1D", + "96", + "8F", + "25", + "26", + "33", + "24", + "32", + "20", + "22", + "23", + "2F", + "21", + "2A", + "2E", + "2D", + "30", + "29", + "27", + "28" + ], + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.washerWashingTime": { + "supportedWashingTimes": { + "value": null + }, + "washingTime": { + "value": null + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2025-04-25T07:40:16.819Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": false, + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": true, + "timestamp": "2025-04-25T07:40:12.942Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": { + "newVersion": "00000000", + "currentVersion": "00000000", + "moduleType": "mainController" + }, + "timestamp": "2025-04-25T08:13:47.829Z" + }, + "otnDUID": { + "value": "2DCB2ZD44WHDW", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-04-25T07:40:13.556Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-04-25T07:40:13.556Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2025-04-25T07:40:13.556Z" + }, + "progress": { + "value": null + } + }, + "sec.smartthingsHub": { + "threadHardwareAvailability": { + "value": null + }, + "availability": { + "value": null + }, + "deviceId": { + "value": null + }, + "zigbeeHardwareAvailability": { + "value": null + }, + "version": { + "value": null + }, + "threadRequiresExternalHardware": { + "value": null + }, + "zigbeeRequiresExternalHardware": { + "value": null + }, + "eui": { + "value": null + }, + "lastOnboardingResult": { + "value": null + }, + "zwaveHardwareAvailability": { + "value": null + }, + "zwaveRequiresExternalHardware": { + "value": null + }, + "state": { + "value": null + }, + "onboardingProgress": { + "value": null + }, + "lastOnboardingErrorCode": { + "value": null + } + }, + "custom.washerSpinLevel": { + "washerSpinLevel": { + "value": "1000", + "timestamp": "2025-04-25T07:49:25.157Z" + }, + "supportedWasherSpinLevel": { + "value": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ], + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.dryerDryingTime": { + "supportedDryingTime": { + "value": null + }, + "dryingTime": { + "value": null + } + }, + "samsungce.washerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "minimumReservableTime": { + "value": 165, + "unit": "min", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null + } + }, + "samsungce.clothingExtraCare": { + "operationMode": { + "value": "off", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "userLocation": { + "value": "indoor", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20374641", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "20010002001811364AA30277008E0000", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "description": { + "value": "DA_WM_TP1_21_COMMON_WD7000B/DC92-03724A_001A", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "releaseYear": { + "value": 24, + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "binaryId": { + "value": "DA_WM_TP1_21_COMMON", + "timestamp": "2025-04-25T08:13:49.565Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-04-25T08:13:49.565Z" + } + }, + "samsungce.washerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "samsungce.quickControl": { + "version": { + "value": "1.0", + "timestamp": "2025-04-25T08:07:13.012Z" + } + }, + "samsungce.audioVolumeLevel": { + "volumeLevel": { + "value": 0, + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "volumeLevelRange": { + "value": { + "minimum": 0, + "maximum": 1, + "step": 1 + }, + "timestamp": "2025-04-25T07:40:12.942Z" + } + }, + "custom.washerRinseCycles": { + "supportedWasherRinseCycles": { + "value": ["0", "1", "2", "3", "4", "5"], + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "washerRinseCycles": { + "value": "2", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.detergentAutoReplenishment": { + "neutralDetergentType": { + "value": "none", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularDetergentRemainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "babyDetergentRemainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "neutralDetergentRemainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "neutralDetergentAlarmEnabled": { + "value": false, + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "neutralDetergentOrderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "babyDetergentInitialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "babyDetergentType": { + "value": "none", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "neutralDetergentInitialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularDetergentDosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "babyDetergentDosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularDetergentOrderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularDetergentType": { + "value": "none", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularDetergentInitialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularDetergentAlarmEnabled": { + "value": false, + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "neutralDetergentDosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "babyDetergentOrderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "babyDetergentAlarmEnabled": { + "value": false, + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.washerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "presets": { + "value": { + "F1": {}, + "F2": {}, + "F3": {}, + "F4": {}, + "F5": {}, + "F6": {}, + "F7": {}, + "F8": {}, + "F9": {}, + "FA": {} + }, + "timestamp": "2025-04-25T07:40:12.942Z" + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": "None", + "timestamp": "2025-04-25T07:40:12.942Z" + } + }, + "samsungce.flexibleAutoDispenseDetergent": { + "remainingAmount": { + "value": "normal", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "amount": { + "value": "standard", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "supportedDensity": { + "value": null + }, + "density": { + "value": null + }, + "supportedAmount": { + "value": ["none", "less", "standard", "extra"], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "availableTypes": { + "value": ["regularSoftener", "regularDetergent"], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "type": { + "value": "regularSoftener", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "supportedTypes": { + "value": null + }, + "recommendedAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "custom.washerAutoDetergent": { + "washerAutoDetergent": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wm_100001.json b/tests/components/smartthings/fixtures/device_status/da_wm_wm_100001.json new file mode 100644 index 00000000000..b3b01762099 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wm_100001.json @@ -0,0 +1,154 @@ +{ + "components": { + "main": { + "ocf": { + "st": { + "value": null, + "timestamp": "2020-10-06T23:01:03.011Z" + }, + "mndt": { + "value": null, + "timestamp": "2021-01-28T11:54:37.203Z" + }, + "mnfv": { + "value": null, + "timestamp": "2020-12-20T14:21:43.766Z" + }, + "mnhw": { + "value": null, + "timestamp": "2021-01-25T22:57:01.985Z" + }, + "di": { + "value": "C0972771-01D0-0000-0000-000000000000", + "timestamp": "2019-08-10T18:37:20.487Z" + }, + "mnsl": { + "value": null, + "timestamp": "2020-12-20T14:21:31.219Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2019-08-10T18:37:20.514Z" + }, + "n": { + "value": "Washer", + "timestamp": "2019-08-10T18:37:20.555Z" + }, + "mnmo": { + "value": "TP6X_WA54M8750AV|20183944|20000101001111000100000000000000", + "timestamp": "2019-08-10T18:37:20.409Z" + }, + "vid": { + "value": "DA-WM-WM-100001", + "timestamp": "2019-08-10T18:37:20.381Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2019-08-10T18:37:20.436Z" + }, + "mnml": { + "value": null, + "timestamp": "2021-01-28T11:54:37.092Z" + }, + "mnpv": { + "value": null, + "timestamp": "2021-01-26T20:55:28.663Z" + }, + "mnos": { + "value": null, + "timestamp": "2021-01-26T20:55:28.411Z" + }, + "pi": { + "value": "shp", + "timestamp": "2019-08-10T18:37:20.457Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2019-08-10T18:37:20.534Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-04-06T17:30:05.372Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100103, + "timestamp": "2022-11-01T11:53:01.255Z" + } + }, + "refresh": {}, + "samsungce.washerOperatingState": { + "washerJobState": { + "value": "none", + "timestamp": "2025-04-18T13:17:00.432Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2025-04-18T13:17:00.432Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-01T11:53:01.255Z" + }, + "scheduledJobs": { + "value": null + }, + "scheduledPhases": { + "value": null + }, + "progress": { + "value": null + }, + "remainingTimeStr": { + "value": "00:57", + "timestamp": "2025-04-18T13:17:00.432Z" + }, + "washerJobPhase": { + "value": null + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": 57, + "unit": "min", + "timestamp": "2025-04-18T13:17:00.432Z" + } + }, + "execute": { + "data": { + "value": null, + "data": {}, + "timestamp": "2020-10-05T02:10:50.602Z" + } + }, + "washerOperatingState": { + "completionTime": { + "value": "2025-04-18T14:14:00Z", + "timestamp": "2025-04-18T13:17:00.432Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-04-18T13:17:00.432Z" + }, + "washerJobState": { + "value": "none", + "timestamp": "2025-04-18T13:17:00.432Z" + }, + "supportedMachineStates": { + "value": null, + "timestamp": "2020-08-14T14:25:00.803Z" + } + }, + "switch": { + "switch": { + "value": null, + "timestamp": "2020-09-13T18:32:28.637Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wm_01011.json b/tests/components/smartthings/fixtures/devices/da_wm_wm_01011.json new file mode 100644 index 00000000000..0099d937b0e --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wm_01011.json @@ -0,0 +1,296 @@ +{ + "items": [ + { + "deviceId": "b854ca5f-dc54-140d-6349-758b4d973c41", + "name": "[washer] Samsung", + "label": "Machine \u00e0 Laver", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WM-01011", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "28a81a30-8fe2-4b9c-ab6b-5bccb73bce02", + "ownerId": "4c4ceeed-d4eb-01fd-6099-53ec206b5fd5", + "roomId": "fdb09f2a-38b5-4fb8-8d65-aee55e343948", + "deviceTypeName": "Samsung OCF Washer", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "logTrigger", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "washerOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dryerDryLevel", + "version": 1 + }, + { + "id": "custom.dryerWrinklePrevent", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "custom.washerAutoDetergent", + "version": 1 + }, + { + "id": "custom.washerAutoSoftener", + "version": 1 + }, + { + "id": "custom.washerRinseCycles", + "version": 1 + }, + { + "id": "custom.washerSoilLevel", + "version": 1 + }, + { + "id": "custom.washerSpinLevel", + "version": 1 + }, + { + "id": "custom.washerWaterTemperature", + "version": 1 + }, + { + "id": "samsungce.audioVolumeLevel", + "version": 1 + }, + { + "id": "samsungce.autoDispenseDetergent", + "version": 1 + }, + { + "id": "samsungce.autoDispenseSoftener", + "version": 1 + }, + { + "id": "samsungce.detergentOrder", + "version": 1 + }, + { + "id": "samsungce.detergentState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.dryerDryingTime", + "version": 1 + }, + { + "id": "samsungce.detergentAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.softenerAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.flexibleAutoDispenseDetergent", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softenerOrder", + "version": 1 + }, + { + "id": "samsungce.softenerState", + "version": 1 + }, + { + "id": "samsungce.washerBubbleSoak", + "version": 1 + }, + { + "id": "samsungce.washerCycle", + "version": 1 + }, + { + "id": "samsungce.washerCyclePreset", + "version": 1 + }, + { + "id": "samsungce.washerDelayEnd", + "version": 1 + }, + { + "id": "samsungce.washerFreezePrevent", + "version": 1 + }, + { + "id": "samsungce.washerLabelScanCyclePreset", + "version": 1 + }, + { + "id": "samsungce.washerOperatingState", + "version": 1 + }, + { + "id": "samsungce.washerWashingTime", + "version": 1 + }, + { + "id": "samsungce.washerWaterLevel", + "version": 1 + }, + { + "id": "samsungce.washerWaterValve", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.waterConsumptionReport", + "version": 1 + }, + { + "id": "samsungce.clothingExtraCare", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.energyPlanner", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + }, + { + "id": "sec.smartthingsHub", + "version": 1, + "ephemeral": true + } + ], + "categories": [ + { + "name": "Washer", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "hca.main", + "label": "hca.main", + "capabilities": [ + { + "id": "hca.washerMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-04-25T07:40:06.100Z", + "profile": { + "id": "76a4a88a-f715-34f8-961a-b31e4faccfda" + }, + "ocf": { + "ocfDeviceType": "oic.d.washer", + "name": "[washer] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_WM_TP1_21_COMMON|20374641|20010002001811364AA30277008E0000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "DA_WM_TP1_21_COMMON_30240927", + "vendorId": "DA-WM-WM-01011", + "vendorResourceClientServerVersion": "Realtek Release 3.1.240801", + "lastSignupTime": "2025-04-25T07:40:05.863149341Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wm_100001.json b/tests/components/smartthings/fixtures/devices/da_wm_wm_100001.json new file mode 100644 index 00000000000..c1a4cd12578 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wm_100001.json @@ -0,0 +1,84 @@ +{ + "items": [ + { + "deviceId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "Washer", + "label": "Washer", + "manufacturerName": "Samsung Electronics", + "presentationId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "ownerId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "deviceTypeName": "Samsung OCF Washer", + "components": [ + { + "id": "main", + "label": "Washer", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "washerOperatingState", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.washerOperatingState", + "version": 1 + } + ], + "categories": [ + { + "name": "Washer", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2019-08-10T18:37:20Z", + "profile": { + "id": "REDACTED" + }, + "ocf": { + "ocfDeviceType": "oic.d.washer", + "name": "Washer", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP6X_WA54M8750AV|20183944|20000101001111000100000000000000", + "vendorId": "DA-WM-WM-100001", + "lastSignupTime": "2021-01-16T06:29:39.379382Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 3aac14c819d..61cecdbd364 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -1947,6 +1947,243 @@ 'state': 'on', }) # --- +# name: test_all_entities[da_wm_wm_01011][binary_sensor.machine_a_laver_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.machine_a_laver_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][binary_sensor.machine_a_laver_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Machine à Laver Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.machine_a_laver_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][binary_sensor.machine_a_laver_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.machine_a_laver_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][binary_sensor.machine_a_laver_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Machine à Laver Power', + }), + 'context': , + 'entity_id': 'binary_sensor.machine_a_laver_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][binary_sensor.machine_a_laver_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.machine_a_laver_remote_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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][binary_sensor.machine_a_laver_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Machine à Laver Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.machine_a_laver_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_wm_wm_100001][binary_sensor.washer_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washer_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_100001][binary_sensor.washer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washer Power', + }), + 'context': , + 'entity_id': 'binary_sensor.washer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_100001][binary_sensor.washer_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washer_remote_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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_100001][binary_sensor.washer_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.washer_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_motion-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 59ad2cff19b..d70d9a1dcfc 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -959,6 +959,72 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_wm_wm_01011] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'b854ca5f-dc54-140d-6349-758b4d973c41', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_TP1_21_COMMON', + 'model_id': None, + 'name': 'Machine à Laver', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_WM_TP1_21_COMMON_30240927', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_wm_wm_100001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP6X_WA54M8750AV', + 'model_id': None, + 'name': 'Washer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[ecobee_sensor] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_number.ambr b/tests/components/smartthings/snapshots/test_number.ambr index 66aade5b958..ee8dd42712a 100644 --- a/tests/components/smartthings/snapshots/test_number.ambr +++ b/tests/components/smartthings/snapshots/test_number.ambr @@ -16,7 +16,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'number', - 'entity_category': None, + 'entity_category': , 'entity_id': 'number.washer_rinse_cycles', 'has_entity_name': True, 'hidden_by': None, @@ -73,7 +73,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'number', - 'entity_category': None, + 'entity_category': , 'entity_id': 'number.washing_machine_rinse_cycles', 'has_entity_name': True, 'hidden_by': None, @@ -113,3 +113,60 @@ 'state': '2', }) # --- +# name: test_all_entities[da_wm_wm_01011][number.machine_a_laver_rinse_cycles-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.machine_a_laver_rinse_cycles', + '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': 'Rinse cycles', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_rinse_cycles', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_custom.washerRinseCycles_washerRinseCycles_washerRinseCycles', + 'unit_of_measurement': 'cycles', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][number.machine_a_laver_rinse_cycles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Machine à Laver Rinse cycles', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'cycles', + }), + 'context': , + 'entity_id': 'number.machine_a_laver_rinse_cycles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_select.ambr b/tests/components/smartthings/snapshots/test_select.ambr index 06185e09547..17d8e10d230 100644 --- a/tests/components/smartthings/snapshots/test_select.ambr +++ b/tests/components/smartthings/snapshots/test_select.ambr @@ -347,3 +347,239 @@ 'state': 'run', }) # --- +# name: test_all_entities[da_wm_wm_01011][select.machine_a_laver-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.machine_a_laver', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_washerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][select.machine_a_laver-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Machine à Laver', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.machine_a_laver', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'run', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][select.machine_a_laver_detergent_dispense_amount-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'less', + 'standard', + 'extra', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.machine_a_laver_detergent_dispense_amount', + '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': 'Detergent dispense amount', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'detergent_amount', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_samsungce.autoDispenseDetergent_amount_amount', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][select.machine_a_laver_detergent_dispense_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Machine à Laver Detergent dispense amount', + 'options': list([ + 'none', + 'less', + 'standard', + 'extra', + ]), + }), + 'context': , + 'entity_id': 'select.machine_a_laver_detergent_dispense_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'standard', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][select.machine_a_laver_flexible_compartment_dispense_amount-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'less', + 'standard', + 'extra', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.machine_a_laver_flexible_compartment_dispense_amount', + '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': 'Flexible compartment dispense amount', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flexible_detergent_amount', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_samsungce.flexibleAutoDispenseDetergent_amount_amount', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][select.machine_a_laver_flexible_compartment_dispense_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Machine à Laver Flexible compartment dispense amount', + 'options': list([ + 'none', + 'less', + 'standard', + 'extra', + ]), + }), + 'context': , + 'entity_id': 'select.machine_a_laver_flexible_compartment_dispense_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'standard', + }) +# --- +# name: test_all_entities[da_wm_wm_100001][select.washer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'run', + 'pause', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.washer', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_washerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_100001][select.washer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer', + 'options': list([ + 'run', + 'pause', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'select.washer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 0abd65ef242..ad073a1d670 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1364,7 +1364,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1402,7 +1402,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'AC Office Granit Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -1793,7 +1793,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1831,7 +1831,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Office AirFree Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -2222,7 +2222,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2260,7 +2260,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Aire Dormitorio Principal Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -4000,7 +4000,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -4038,7 +4038,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Refrigerator Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -4277,7 +4277,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -4315,7 +4315,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Refrigerator Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -4554,7 +4554,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -4592,7 +4592,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Frigo Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -5128,7 +5128,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -5166,7 +5166,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Eco Heating System Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -5637,7 +5637,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -5675,7 +5675,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Dishwasher Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -6104,7 +6104,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -6142,7 +6142,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'AirDresser Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -6571,7 +6571,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -6609,7 +6609,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Dryer Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -7038,7 +7038,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -7076,7 +7076,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Seca-Roupa Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -7507,7 +7507,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -7545,7 +7545,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Washer Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -7976,7 +7976,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -8014,7 +8014,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Washing Machine Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -8025,6 +8025,719 @@ 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_washerOperatingState_completionTime_completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Machine à Laver Completion time', + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-25T10:34:12+00:00', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_energy', + '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': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Machine à Laver Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.8', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_energy_difference', + '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': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Machine à Laver Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_energy_saved', + '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': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Machine à Laver Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_job_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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_job_state', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_washerOperatingState_washerJobState_washerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Machine à Laver Job state', + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'wash', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_machine_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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_machine_state', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_washerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Machine à Laver Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'run', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_power', + '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': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Machine à Laver Power', + 'power_consumption_end': '2025-04-25T08:43:46Z', + 'power_consumption_start': '2025-04-25T08:28:43Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_power_energy', + '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': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Machine à Laver Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_water_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_water_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water consumption', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_consumption', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_samsungce.waterConsumptionReport_waterConsumption_waterConsumption', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_water_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Machine à Laver Water consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_water_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1642.2', + }) +# --- +# name: test_all_entities[da_wm_wm_100001][sensor.washer_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_washerOperatingState_completionTime_completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_100001][sensor.washer_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Washer Completion time', + }), + 'context': , + 'entity_id': 'sensor.washer_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-18T14:14:00+00:00', + }) +# --- +# name: test_all_entities[da_wm_wm_100001][sensor.washer_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_job_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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_job_state', + 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_washerOperatingState_washerJobState_washerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_100001][sensor.washer_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washer Job state', + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), + 'context': , + 'entity_id': 'sensor.washer_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wm_100001][sensor.washer_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_machine_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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_machine_state', + 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_washerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_100001][sensor.washer_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washer Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.washer_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- # name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 395a9943f98..e1b68971fb8 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -93,6 +93,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_sabbath_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.refrigerator_sabbath_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': 'Sabbath mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sabbath_mode', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_samsungce.sabbathMode_status_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_sabbath_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Sabbath mode', + }), + 'context': , + 'entity_id': 'switch.refrigerator_sabbath_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_maker-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -246,7 +293,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.dryer_wrinkle_prevent', 'has_entity_name': True, 'hidden_by': None, @@ -293,7 +340,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.seca_roupa_wrinkle_prevent', 'has_entity_name': True, 'hidden_by': None, @@ -340,7 +387,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.washing_machine_bubble_soak', 'has_entity_name': True, 'hidden_by': None, @@ -375,6 +422,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wm_01011][switch.machine_a_laver_bubble_soak-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.machine_a_laver_bubble_soak', + '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': 'Bubble Soak', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bubble_soak', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_samsungce.washerBubbleSoak_status_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][switch.machine_a_laver_bubble_soak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Machine à Laver Bubble Soak', + }), + 'context': , + 'entity_id': 'switch.machine_a_laver_bubble_soak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[generic_ef00_v1][switch.thermostat_kuche-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index 9f9d8d66317..22ca94df81a 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability +from pysmartthings.models import HealthStatus import pytest from syrupy import SnapshotAssertion @@ -11,12 +12,17 @@ from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.script import scripts_with_entity from homeassistant.components.smartthings import DOMAIN, MAIN -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component -from . import setup_integration, snapshot_smartthings_entities, trigger_update +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) from tests.common import MockConfigEntry @@ -60,6 +66,47 @@ async def test_state_update( assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_ON +@pytest.mark.parametrize("device_fixture", ["da_ref_normal_000001"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_OFF + + await trigger_health_update( + hass, devices, "7db87911-7dce-1cf2-7119-b953432a2f09", HealthStatus.OFFLINE + ) + + assert ( + hass.states.get("binary_sensor.refrigerator_cooler_door").state + == STATE_UNAVAILABLE + ) + + await trigger_health_update( + hass, devices, "7db87911-7dce-1cf2-7119-b953432a2f09", HealthStatus.ONLINE + ) + + assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_OFF + + +@pytest.mark.parametrize("device_fixture", ["da_ref_normal_000001"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert ( + hass.states.get("binary_sensor.refrigerator_cooler_door").state + == STATE_UNAVAILABLE + ) + + @pytest.mark.parametrize( ("device_fixture", "unique_id", "suggested_object_id", "issue_string", "entity_id"), [ diff --git a/tests/components/smartthings/test_button.py b/tests/components/smartthings/test_button.py index 4a348d079ca..5c5f98912e2 100644 --- a/tests/components/smartthings/test_button.py +++ b/tests/components/smartthings/test_button.py @@ -4,16 +4,22 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from pysmartthings import Capability, Command +from pysmartthings.models import HealthStatus import pytest from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.smartthings import MAIN -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration, snapshot_smartthings_entities +from . import setup_integration, snapshot_smartthings_entities, trigger_health_update from tests.common import MockConfigEntry @@ -54,3 +60,38 @@ async def test_press( Command.STOP, MAIN, ) + + +@pytest.mark.parametrize("device_fixture", ["da_ks_microwave_0101x"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("button.microwave_stop").state == STATE_UNKNOWN + + await trigger_health_update( + hass, devices, "2bad3237-4886-e699-1b90-4a51a3d55c8a", HealthStatus.OFFLINE + ) + + assert hass.states.get("button.microwave_stop").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "2bad3237-4886-e699-1b90-4a51a3d55c8a", HealthStatus.ONLINE + ) + + assert hass.states.get("button.microwave_stop").state == STATE_UNKNOWN + + +@pytest.mark.parametrize("device_fixture", ["da_ks_microwave_0101x"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("button.microwave_stop").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 75b864598bd..138601ec08b 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -4,6 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, call from pysmartthings import Attribute, Capability, Command, Status +from pysmartthings.models import HealthStatus import pytest from syrupy import SnapshotAssertion @@ -36,6 +37,8 @@ from homeassistant.const import ( ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_OFF, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant @@ -45,6 +48,7 @@ from . import ( set_attribute_value, setup_integration, snapshot_smartthings_entities, + trigger_health_update, trigger_update, ) @@ -857,3 +861,38 @@ async def test_thermostat_state_attributes_update( ) assert hass.states.get("climate.asd").attributes[state_attribute] == expected_value + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("climate.ac_office_granit").state == STATE_OFF + + await trigger_health_update( + hass, devices, "96a5ef74-5832-a84b-f1f7-ca799957065d", HealthStatus.OFFLINE + ) + + assert hass.states.get("climate.ac_office_granit").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "96a5ef74-5832-a84b-f1f7-ca799957065d", HealthStatus.ONLINE + ) + + assert hass.states.get("climate.ac_office_granit").state == STATE_OFF + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("climate.ac_office_granit").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_cover.py b/tests/components/smartthings/test_cover.py index 37f12b44880..559c6821204 100644 --- a/tests/components/smartthings/test_cover.py +++ b/tests/components/smartthings/test_cover.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command, Status +from pysmartthings.models import HealthStatus import pytest from syrupy import SnapshotAssertion @@ -20,12 +21,18 @@ from homeassistant.const import ( SERVICE_SET_COVER_POSITION, STATE_OPEN, STATE_OPENING, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration, snapshot_smartthings_entities, trigger_update +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) from tests.common import MockConfigEntry @@ -190,3 +197,38 @@ async def test_position_update( ) assert hass.states.get("cover.curtain_1a").attributes[ATTR_CURRENT_POSITION] == 50 + + +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.curtain_1a").state == STATE_OPEN + + await trigger_health_update( + hass, devices, "571af102-15db-4030-b76b-245a691f74a5", HealthStatus.OFFLINE + ) + + assert hass.states.get("cover.curtain_1a").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "571af102-15db-4030-b76b-245a691f74a5", HealthStatus.ONLINE + ) + + assert hass.states.get("cover.curtain_1a").state == STATE_OPEN + + +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("cover.curtain_1a").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_event.py b/tests/components/smartthings/test_event.py index 34a96e9c6b4..b9a6fc8be86 100644 --- a/tests/components/smartthings/test_event.py +++ b/tests/components/smartthings/test_event.py @@ -4,15 +4,21 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from pysmartthings import Attribute, Capability +from pysmartthings.models import HealthStatus import pytest from syrupy import SnapshotAssertion from homeassistant.components.event import ATTR_EVENT_TYPES -from homeassistant.const import STATE_UNKNOWN, Platform +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, snapshot_smartthings_entities, trigger_update +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) from tests.common import MockConfigEntry @@ -97,3 +103,48 @@ async def test_supported_button_values_update( assert hass.states.get("event.livingroom_smart_switch_button1").attributes[ ATTR_EVENT_TYPES ] == ["pushed", "held", "down_hold", "pushed_2x"] + + +@pytest.mark.parametrize("device_fixture", ["heatit_zpushwall"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state == STATE_UNKNOWN + ) + + await trigger_health_update( + hass, devices, "5e5b97f3-3094-44e6-abc0-f61283412d6a", HealthStatus.OFFLINE + ) + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state + == STATE_UNAVAILABLE + ) + + await trigger_health_update( + hass, devices, "5e5b97f3-3094-44e6-abc0-f61283412d6a", HealthStatus.ONLINE + ) + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state == STATE_UNKNOWN + ) + + +@pytest.mark.parametrize("device_fixture", ["heatit_zpushwall"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state + == STATE_UNAVAILABLE + ) diff --git a/tests/components/smartthings/test_fan.py b/tests/components/smartthings/test_fan.py index 58287355381..04196417690 100644 --- a/tests/components/smartthings/test_fan.py +++ b/tests/components/smartthings/test_fan.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock from pysmartthings import Capability, Command +from pysmartthings.models import HealthStatus import pytest from syrupy import SnapshotAssertion @@ -18,12 +19,14 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_OFF, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration, snapshot_smartthings_entities +from . import setup_integration, snapshot_smartthings_entities, trigger_health_update from tests.common import MockConfigEntry @@ -166,3 +169,38 @@ async def test_set_preset_mode( MAIN, argument="turbo", ) + + +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("fan.fake_fan").state == STATE_OFF + + await trigger_health_update( + hass, devices, "f1af21a2-d5a1-437c-b10a-b34a87394b71", HealthStatus.OFFLINE + ) + + assert hass.states.get("fan.fake_fan").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "f1af21a2-d5a1-437c-b10a-b34a87394b71", HealthStatus.ONLINE + ) + + assert hass.states.get("fan.fake_fan").state == STATE_OFF + + +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("fan.fake_fan").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index 56eadde748b..46f8f3ae7a3 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -4,6 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, call from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus import pytest from syrupy import SnapshotAssertion @@ -28,6 +29,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant, State @@ -37,6 +39,7 @@ from . import ( set_attribute_value, setup_integration, snapshot_smartthings_entities, + trigger_health_update, trigger_update, ) @@ -413,3 +416,38 @@ async def test_color_mode_after_startup( hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] is ColorMode.COLOR_TEMP ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("light.standing_light").state == STATE_OFF + + await trigger_health_update( + hass, devices, "cb958955-b015-498c-9e62-fc0c51abd054", HealthStatus.OFFLINE + ) + + assert hass.states.get("light.standing_light").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "cb958955-b015-498c-9e62-fc0c51abd054", HealthStatus.ONLINE + ) + + assert hass.states.get("light.standing_light").state == STATE_OFF + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("light.standing_light").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_lock.py b/tests/components/smartthings/test_lock.py index 28191eceb9a..48e83f479fa 100644 --- a/tests/components/smartthings/test_lock.py +++ b/tests/components/smartthings/test_lock.py @@ -3,16 +3,28 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus import pytest from syrupy import SnapshotAssertion from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.components.smartthings.const import MAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_LOCK, + SERVICE_UNLOCK, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration, snapshot_smartthings_entities, trigger_update +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) from tests.common import MockConfigEntry @@ -83,3 +95,38 @@ async def test_state_update( ) assert hass.states.get("lock.basement_door_lock").state == LockState.UNLOCKED + + +@pytest.mark.parametrize("device_fixture", ["yale_push_button_deadbolt_lock"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("lock.basement_door_lock").state == LockState.LOCKED + + await trigger_health_update( + hass, devices, "a9f587c5-5d8b-4273-8907-e7f609af5158", HealthStatus.OFFLINE + ) + + assert hass.states.get("lock.basement_door_lock").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "a9f587c5-5d8b-4273-8907-e7f609af5158", HealthStatus.ONLINE + ) + + assert hass.states.get("lock.basement_door_lock").state == LockState.LOCKED + + +@pytest.mark.parametrize("device_fixture", ["yale_push_button_deadbolt_lock"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("lock.basement_door_lock").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_media_player.py b/tests/components/smartthings/test_media_player.py index b7cecfe8408..e3f3652c0ed 100644 --- a/tests/components/smartthings/test_media_player.py +++ b/tests/components/smartthings/test_media_player.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command, Status +from pysmartthings.models import HealthStatus import pytest from syrupy import SnapshotAssertion @@ -34,12 +35,18 @@ from homeassistant.const import ( SERVICE_VOLUME_UP, STATE_OFF, STATE_PLAYING, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration, snapshot_smartthings_entities, trigger_update +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) from tests.common import MockConfigEntry @@ -430,3 +437,38 @@ async def test_state_update( ) assert hass.states.get("media_player.soundbar").state == STATE_OFF + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("media_player.soundbar").state == STATE_PLAYING + + await trigger_health_update( + hass, devices, "afcf3b91-0000-1111-2222-ddff2a0a6577", HealthStatus.OFFLINE + ) + + assert hass.states.get("media_player.soundbar").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "afcf3b91-0000-1111-2222-ddff2a0a6577", HealthStatus.ONLINE + ) + + assert hass.states.get("media_player.soundbar").state == STATE_PLAYING + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("media_player.soundbar").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_number.py b/tests/components/smartthings/test_number.py index 578b94e050f..fa485776c37 100644 --- a/tests/components/smartthings/test_number.py +++ b/tests/components/smartthings/test_number.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus import pytest from syrupy import SnapshotAssertion @@ -12,11 +13,16 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE, ) from homeassistant.components.smartthings import MAIN -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.helpers import entity_registry as er -from . import setup_integration, snapshot_smartthings_entities, trigger_update +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) from tests.common import MockConfigEntry @@ -79,3 +85,38 @@ async def test_state_update( ) assert hass.states.get("number.washer_rinse_cycles").state == "3" + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wm_000001"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("number.washer_rinse_cycles").state == "2" + + await trigger_health_update( + hass, devices, "f984b91d-f250-9d42-3436-33f09a422a47", HealthStatus.OFFLINE + ) + + assert hass.states.get("number.washer_rinse_cycles").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "f984b91d-f250-9d42-3436-33f09a422a47", HealthStatus.ONLINE + ) + + assert hass.states.get("number.washer_rinse_cycles").state == "2" + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wm_000001"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("number.washer_rinse_cycles").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_select.py b/tests/components/smartthings/test_select.py index 2c5c55239f2..ce3bea08ca2 100644 --- a/tests/components/smartthings/test_select.py +++ b/tests/components/smartthings/test_select.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus import pytest from syrupy import SnapshotAssertion @@ -12,7 +13,7 @@ from homeassistant.components.select import ( SERVICE_SELECT_OPTION, ) from homeassistant.components.smartthings import MAIN -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 ServiceValidationError from homeassistant.helpers import entity_registry as er @@ -21,6 +22,7 @@ from . import ( set_attribute_value, setup_integration, snapshot_smartthings_entities, + trigger_health_update, trigger_update, ) @@ -119,3 +121,38 @@ async def test_select_option_without_remote_control( blocking=True, ) devices.execute_device_command.assert_not_called() + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("select.dryer").state == "stop" + + await trigger_health_update( + hass, devices, "02f7256e-8353-5bdd-547f-bd5b1647e01b", HealthStatus.OFFLINE + ) + + assert hass.states.get("select.dryer").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "02f7256e-8353-5bdd-547f-bd5b1647e01b", HealthStatus.ONLINE + ) + + assert hass.states.get("select.dryer").state == "stop" + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("select.dryer").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index e90c177bd6d..ecdcd700cab 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability +from pysmartthings.models import HealthStatus import pytest from syrupy import SnapshotAssertion @@ -11,12 +12,17 @@ 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 from homeassistant.components.smartthings.const import DOMAIN, MAIN -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component -from . import setup_integration, snapshot_smartthings_entities, trigger_update +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) from tests.common import MockConfigEntry @@ -296,3 +302,44 @@ async def test_create_issue( # Assert the issue is no longer present assert not issue_registry.async_get_issue(DOMAIN, issue_id) assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.ac_office_granit_temperature").state == "25" + + await trigger_health_update( + hass, devices, "96a5ef74-5832-a84b-f1f7-ca799957065d", HealthStatus.OFFLINE + ) + + assert ( + hass.states.get("sensor.ac_office_granit_temperature").state + == STATE_UNAVAILABLE + ) + + await trigger_health_update( + hass, devices, "96a5ef74-5832-a84b-f1f7-ca799957065d", HealthStatus.ONLINE + ) + + assert hass.states.get("sensor.ac_office_granit_temperature").state == "25" + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert ( + hass.states.get("sensor.ac_office_granit_temperature").state + == STATE_UNAVAILABLE + ) diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index a47ecde7e0d..0f759d8e6b5 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus import pytest from syrupy import SnapshotAssertion @@ -17,13 +18,19 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component -from . import setup_integration, snapshot_smartthings_entities, trigger_update +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) from tests.common import MockConfigEntry @@ -377,3 +384,38 @@ async def test_create_issue( # Assert the issue is no longer present assert not issue_registry.async_get_issue(DOMAIN, issue_id) assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("switch.2nd_floor_hallway").state == STATE_ON + + await trigger_health_update( + hass, devices, "10e06a70-ee7d-4832-85e9-a0a06a7a05bd", HealthStatus.OFFLINE + ) + + assert hass.states.get("switch.2nd_floor_hallway").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "10e06a70-ee7d-4832-85e9-a0a06a7a05bd", HealthStatus.ONLINE + ) + + assert hass.states.get("switch.2nd_floor_hallway").state == STATE_ON + + +@pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("switch.2nd_floor_hallway").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_update.py b/tests/components/smartthings/test_update.py index 8c3d9e1a968..e4b360e0398 100644 --- a/tests/components/smartthings/test_update.py +++ b/tests/components/smartthings/test_update.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus import pytest from syrupy import SnapshotAssertion @@ -12,11 +13,22 @@ from homeassistant.components.update import ( DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration, snapshot_smartthings_entities, trigger_update +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) from tests.common import MockConfigEntry @@ -140,3 +152,38 @@ async def test_state_update_available( ) assert hass.states.get("update.dimmer_debian_firmware").state == STATE_ON + + +@pytest.mark.parametrize("device_fixture", ["centralite"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_OFF + + await trigger_health_update( + hass, devices, "d0268a69-abfb-4c92-a646-61cec2e510ad", HealthStatus.OFFLINE + ) + + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "d0268a69-abfb-4c92-a646-61cec2e510ad", HealthStatus.ONLINE + ) + + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_OFF + + +@pytest.mark.parametrize("device_fixture", ["centralite"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_valve.py b/tests/components/smartthings/test_valve.py index f0ba34c8264..9d2cef65035 100644 --- a/tests/components/smartthings/test_valve.py +++ b/tests/components/smartthings/test_valve.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus import pytest from syrupy import SnapshotAssertion @@ -12,12 +13,18 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration, snapshot_smartthings_entities, trigger_update +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) from tests.common import MockConfigEntry @@ -85,3 +92,38 @@ async def test_state_update( ) assert hass.states.get("valve.volvo").state == ValveState.OPEN + + +@pytest.mark.parametrize("device_fixture", ["virtual_valve"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("valve.volvo").state == ValveState.CLOSED + + await trigger_health_update( + hass, devices, "612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3", HealthStatus.OFFLINE + ) + + assert hass.states.get("valve.volvo").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3", HealthStatus.ONLINE + ) + + assert hass.states.get("valve.volvo").state == ValveState.CLOSED + + +@pytest.mark.parametrize("device_fixture", ["virtual_valve"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("valve.volvo").state == STATE_UNAVAILABLE diff --git a/tests/components/smarty/conftest.py b/tests/components/smarty/conftest.py index a9b518d88f4..fe2fb4c7bab 100644 --- a/tests/components/smarty/conftest.py +++ b/tests/components/smarty/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.smarty import DOMAIN +from homeassistant.components.smarty.const import DOMAIN from homeassistant.const import CONF_HOST from tests.common import MockConfigEntry diff --git a/tests/components/smarty/test_config_flow.py b/tests/components/smarty/test_config_flow.py index fad4f27ca1c..831aca52c73 100644 --- a/tests/components/smarty/test_config_flow.py +++ b/tests/components/smarty/test_config_flow.py @@ -3,8 +3,8 @@ from unittest.mock import AsyncMock from homeassistant.components.smarty.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -114,52 +114,3 @@ async def test_existing_entry( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_import_flow( - hass: HomeAssistant, mock_smarty: AsyncMock, mock_setup_entry: AsyncMock -) -> None: - """Test the import flow.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "192.168.0.2", CONF_NAME: "Smarty"}, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Smarty" - assert result["data"] == {CONF_HOST: "192.168.0.2"} - - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_cannot_connect( - hass: HomeAssistant, mock_smarty: AsyncMock -) -> None: - """Test we handle cannot connect error.""" - - mock_smarty.update.return_value = False - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "192.168.0.2", CONF_NAME: "Smarty"}, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - -async def test_import_unknown_error( - hass: HomeAssistant, mock_smarty: AsyncMock -) -> None: - """Test we handle unknown error.""" - - mock_smarty.update.side_effect = Exception - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "192.168.0.2", CONF_NAME: "Smarty"}, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" diff --git a/tests/components/smarty/test_init.py b/tests/components/smarty/test_init.py index 0366ea9eade..6468fd74507 100644 --- a/tests/components/smarty/test_init.py +++ b/tests/components/smarty/test_init.py @@ -4,68 +4,15 @@ from unittest.mock import AsyncMock from syrupy import SnapshotAssertion -from homeassistant.components.smarty import DOMAIN -from homeassistant.const import CONF_HOST, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import device_registry as dr, issue_registry as ir -from homeassistant.setup import async_setup_component +from homeassistant.components.smarty.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import setup_integration from tests.common import MockConfigEntry -async def test_import_flow( - hass: HomeAssistant, - mock_smarty: AsyncMock, - issue_registry: ir.IssueRegistry, - mock_setup_entry: AsyncMock, -) -> None: - """Test import flow.""" - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}} - ) - await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert (HOMEASSISTANT_DOMAIN, "deprecated_yaml_smarty") in issue_registry.issues - - -async def test_import_flow_already_exists( - hass: HomeAssistant, - mock_smarty: AsyncMock, - issue_registry: ir.IssueRegistry, - mock_setup_entry: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test import flow when entry already exists.""" - mock_config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}} - ) - await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert (HOMEASSISTANT_DOMAIN, "deprecated_yaml_smarty") in issue_registry.issues - - -async def test_import_flow_error( - hass: HomeAssistant, - mock_smarty: AsyncMock, - issue_registry: ir.IssueRegistry, - mock_setup_entry: AsyncMock, -) -> None: - """Test import flow when error occurs.""" - mock_smarty.update.return_value = False - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}} - ) - await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 0 - assert ( - DOMAIN, - "deprecated_yaml_import_issue_cannot_connect", - ) in issue_registry.issues - - async def test_device( hass: HomeAssistant, snapshot: SnapshotAssertion, diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 901d7e547fe..0eb8fda09c5 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -14,6 +14,7 @@ from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component +from homeassistant.util.ssl import create_client_context from tests.common import get_fixture_path @@ -84,6 +85,7 @@ def message(): "Home Assistant", 0, True, + create_client_context(), ) diff --git a/tests/components/sonos/snapshots/test_media_browser.ambr b/tests/components/sonos/snapshots/test_media_browser.ambr index 24f08eaf95b..faa06a9adc2 100644 --- a/tests/components/sonos/snapshots/test_media_browser.ambr +++ b/tests/components/sonos/snapshots/test_media_browser.ambr @@ -3,10 +3,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'album', 'media_content_id': 'object.container.album.musicAlbum', @@ -17,6 +19,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'track', 'media_content_id': 'object.item.audioItem.audioBook', @@ -27,6 +30,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'genre', 'media_content_id': 'object.item.audioItem.audioBroadcast', @@ -48,10 +52,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'album', 'media_content_id': 'FV:2/8', @@ -73,10 +79,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'track', 'media_content_id': 'FV:2/66', @@ -99,6 +107,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'contributing_artist', 'media_content_id': 'A:ARTIST', @@ -109,6 +118,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'artist', 'media_content_id': 'A:ALBUMARTIST', @@ -119,6 +129,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'album', 'media_content_id': 'A:ALBUM', @@ -129,6 +140,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'genre', 'media_content_id': 'A:GENRE', @@ -139,6 +151,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'composer', 'media_content_id': 'A:COMPOSER', @@ -149,6 +162,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'track', 'media_content_id': 'A:TRACKS', @@ -159,6 +173,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'playlist', 'media_content_id': 'A:PLAYLISTS', @@ -173,6 +188,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'album', 'media_content_id': "A:ALBUM/A%20Hard%20Day's%20Night", @@ -183,6 +199,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'album', 'media_content_id': 'A:ALBUM/Abbey%20Road', @@ -193,6 +210,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'album', 'media_content_id': 'A:ALBUM/Between%20Good%20And%20Evil', @@ -203,6 +221,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'album', 'media_content_id': "A:ALBUM/Special%20Characters,'()+", @@ -217,6 +236,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'directory', 'media_content_id': '', @@ -227,6 +247,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'directory', 'media_content_id': '', diff --git a/tests/components/spaceapi/test_init.py b/tests/components/spaceapi/test_init.py index 8c0e897947a..154ddb9253e 100644 --- a/tests/components/spaceapi/test_init.py +++ b/tests/components/spaceapi/test_init.py @@ -94,10 +94,12 @@ SENSOR_OUTPUT = { @pytest.fixture -def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> TestClient: +async def mock_client( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> TestClient: """Start the Home Assistant HTTP component.""" with patch("homeassistant.components.spaceapi", return_value=True): - hass.loop.run_until_complete(async_setup_component(hass, "spaceapi", CONFIG)) + await async_setup_component(hass, "spaceapi", CONFIG) hass.states.async_set( "test.temp1", @@ -126,7 +128,7 @@ def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> Tes "test.hum1", 88, attributes={ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE} ) - return hass.loop.run_until_complete(hass_client()) + return await hass_client() async def test_spaceapi_get(hass: HomeAssistant, mock_client) -> None: diff --git a/tests/components/spotify/snapshots/test_media_browser.ambr b/tests/components/spotify/snapshots/test_media_browser.ambr index 6b217977227..e241893df3b 100644 --- a/tests/components/spotify/snapshots/test_media_browser.ambr +++ b/tests/components/spotify/snapshots/test_media_browser.ambr @@ -3,10 +3,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/current_user_playlists', @@ -17,6 +19,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/current_user_followed_artists', @@ -27,6 +30,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/current_user_saved_albums', @@ -37,6 +41,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/current_user_saved_tracks', @@ -47,6 +52,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/current_user_saved_shows', @@ -57,6 +63,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/current_user_recently_played', @@ -67,6 +74,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/current_user_top_artists', @@ -77,6 +85,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/current_user_top_tracks', @@ -87,6 +96,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/new_releases', @@ -108,10 +118,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:playlist:4WkWJ0EjHEFASDevhM8oPw', @@ -122,6 +134,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:playlist:1RHirWgH1weMsBLi4KOK9d', @@ -143,10 +156,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://32oesphrnacjcf7vw5bf6odx3/spotify:playlist:4WkWJ0EjHEFASDevhM8oPw', @@ -157,6 +172,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://32oesphrnacjcf7vw5bf6odx3/spotify:playlist:1RHirWgH1weMsBLi4KOK9d', @@ -178,10 +194,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01J5TX5A0FF6G5V0QJX6HBC94T', @@ -192,6 +210,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://32oesphrnacjcf7vw5bf6odx3', @@ -213,10 +232,12 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children': list([ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:6akJGriy4njdP8fZTPGjwz', @@ -227,6 +248,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:7N02bJK1amhplZ8yAapRS5', @@ -248,10 +270,12 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:album:56jg3KJcYmfL7RzYmG2O1Q', @@ -262,6 +286,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:album:1l86t4bTNT2j1X0ZBCIv6R', @@ -283,10 +308,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:artist:0lLY20XpZ9yDobkbHI7u1y', @@ -297,6 +324,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:artist:0p4nmQO2msCgU4IF37Wi3j', @@ -318,10 +346,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:playlist:4WkWJ0EjHEFASDevhM8oPw', @@ -332,6 +362,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:playlist:1RHirWgH1weMsBLi4KOK9d', @@ -353,10 +384,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:71dMjqJ8UJV700zYs5YZCh', @@ -367,6 +400,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:71dMjqJ8UJV700zYs5YZCh', @@ -388,10 +422,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:album:57MSBg5pBQZH5bfLVDmeuP', @@ -402,6 +438,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:album:3DQueEd1Ft9PHWgovDzPKh', @@ -423,10 +460,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:show:5OzkclFjD6iAjtAuo7aIYt', @@ -437,6 +476,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:show:6XYRres0KZtnTqKcLavWR2', @@ -458,10 +498,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:2pj2A25YQK4uMxhZheNx7R', @@ -472,6 +514,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:2lKOI1nwP5qZtZC7TGQVY8', @@ -493,10 +536,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:artist:74Yus6IHfa3tWZzXXAYtS2', @@ -507,6 +552,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:artist:6s5ubAp65wXoTZefE01RNR', @@ -528,10 +574,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:3oRoMXsP2NRzm51lldj1RO', @@ -542,6 +590,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:69zgu5rlAie3IPZOEXLxyS', @@ -563,10 +612,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:album:5SGtrmYbIo0Dsg4kJ4qjM6', @@ -577,6 +628,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:album:713lZ7AF55fEFSQgcttj9y', @@ -598,10 +650,12 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children': list([ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:4rzfv0JLZfVhOhbSQ8o5jZ', @@ -612,6 +666,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:5o3jMYOSbaVz3tkgwhELSV', @@ -622,6 +677,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:4Cy0NHJ8Gh0xMdwyM9RkQm', @@ -632,6 +688,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:6hvFrZNocdt2FcKGCSY5NI', @@ -642,6 +699,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:2E2znCPaS8anQe21GLxcvJ', @@ -652,6 +710,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:episode:3o0RYoo5iOMKSmEbunsbvW', @@ -673,10 +732,12 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children': list([ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:episode:3ssmxnilHYaKhwRWoBGMbU', @@ -687,6 +748,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:episode:1bbj9aqeeZ3UMUlcWN0S03', diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 6b4032323d0..354840c518e 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -317,6 +317,8 @@ async def test_templates_with_yaml( state = hass.states.get("sensor.get_values_with_template") assert state.state == STATE_UNAVAILABLE + assert CONF_ICON not in state.attributes + assert "entity_picture" not in state.attributes hass.states.async_set("sensor.input1", "on") hass.states.async_set("sensor.input2", "on") @@ -660,3 +662,37 @@ async def test_setup_without_recorder(hass: HomeAssistant) -> None: state = hass.states.get("sensor.get_value") assert state.state == "5" + + +async def test_availability_blocks_value_template( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for sensor.get_value: 'x' is undefined" + config = YAML_CONFIG + config["sql"]["value_template"] = "{{ x - 0 }}" + config["sql"]["availability"] = '{{ states("sensor.input1")=="on" }}' + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + assert error not in caplog.text + + state = hass.states.get("sensor.get_value") + assert state + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set("sensor.input1", "on") + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/squeezebox/test_media_browser.py b/tests/components/squeezebox/test_media_browser.py index 7b11ef30a87..f1ba187a699 100644 --- a/tests/components/squeezebox/test_media_browser.py +++ b/tests/components/squeezebox/test_media_browser.py @@ -65,21 +65,21 @@ async def test_async_browse_media_root( assert response["success"] result = response["result"] for idx, item in enumerate(result["children"]): - assert item["title"] == LIBRARY[idx] + assert item["title"].lower() == LIBRARY[idx] @pytest.mark.parametrize( ("category", "child_count"), [ - ("Favorites", 4), - ("Artists", 4), - ("Albums", 4), - ("Playlists", 4), - ("Genres", 4), - ("New Music", 4), - ("Album Artists", 4), - ("Apps", 3), - ("Radios", 3), + ("favorites", 4), + ("artists", 4), + ("albums", 4), + ("playlists", 4), + ("genres", 4), + ("new music", 4), + ("album artists", 4), + ("apps", 3), + ("radios", 3), ], ) async def test_async_browse_media_with_subitems( diff --git a/tests/components/ssdp/__init__.py b/tests/components/ssdp/__init__.py index b6dcb9d49b5..e01136e051a 100644 --- a/tests/components/ssdp/__init__.py +++ b/tests/components/ssdp/__init__.py @@ -1 +1,27 @@ """Tests for the SSDP integration.""" + +from __future__ import annotations + +from datetime import datetime + +from async_upnp_client.ssdp import udn_from_headers +from async_upnp_client.ssdp_listener import SsdpListener +from async_upnp_client.utils import CaseInsensitiveDict + +from homeassistant.components import ssdp +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def init_ssdp_component(hass: HomeAssistant) -> SsdpListener: + """Initialize ssdp component and get SsdpListener.""" + await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) + await hass.async_block_till_done() + return hass.data[ssdp.DOMAIN][ssdp.SSDP_SCANNER]._ssdp_listeners[0] + + +def _ssdp_headers(headers) -> CaseInsensitiveDict: + """Create a CaseInsensitiveDict with headers and a timestamp.""" + ssdp_headers = CaseInsensitiveDict(headers, _timestamp=datetime.now()) + ssdp_headers["_udn"] = udn_from_headers(ssdp_headers) + return ssdp_headers diff --git a/tests/components/ssdp/conftest.py b/tests/components/ssdp/conftest.py index ac0ac7298a8..61c763ce7d4 100644 --- a/tests/components/ssdp/conftest.py +++ b/tests/components/ssdp/conftest.py @@ -14,9 +14,9 @@ from homeassistant.core import HomeAssistant async def silent_ssdp_listener(): """Patch SsdpListener class, preventing any actual SSDP traffic.""" with ( - patch("homeassistant.components.ssdp.SsdpListener.async_start"), - patch("homeassistant.components.ssdp.SsdpListener.async_stop"), - patch("homeassistant.components.ssdp.SsdpListener.async_search"), + patch("homeassistant.components.ssdp.scanner.SsdpListener.async_start"), + patch("homeassistant.components.ssdp.scanner.SsdpListener.async_stop"), + patch("homeassistant.components.ssdp.scanner.SsdpListener.async_search"), ): # Fixtures are initialized before patches. When the component is started here, # certain functions/methods might not be patched in time. @@ -27,9 +27,9 @@ async def silent_ssdp_listener(): async def disabled_upnp_server(): """Disable UPnpServer.""" with ( - patch("homeassistant.components.ssdp.UpnpServer.async_start"), - patch("homeassistant.components.ssdp.UpnpServer.async_stop"), - patch("homeassistant.components.ssdp._async_find_next_available_port"), + patch("homeassistant.components.ssdp.server.UpnpServer.async_start"), + patch("homeassistant.components.ssdp.server.UpnpServer.async_stop"), + patch("homeassistant.components.ssdp.server._async_find_next_available_port"), ): yield UpnpServer diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 56623b51bb5..a3cc4d9d2bf 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -1,18 +1,16 @@ """Test the SSDP integration.""" -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 -from async_upnp_client.ssdp import udn_from_headers from async_upnp_client.ssdp_listener import SsdpListener -from async_upnp_client.utils import CaseInsensitiveDict import pytest from homeassistant import config_entries from homeassistant.components import ssdp +from homeassistant.components.ssdp import scanner from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, @@ -38,9 +36,10 @@ from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_UPC, SsdpServiceInfo, ) -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from . import _ssdp_headers, init_ssdp_component + from tests.common import ( MockConfigEntry, MockModule, @@ -51,19 +50,6 @@ from tests.common import ( from tests.test_util.aiohttp import AiohttpClientMocker -def _ssdp_headers(headers): - ssdp_headers = CaseInsensitiveDict(headers, _timestamp=datetime.now()) - ssdp_headers["_udn"] = udn_from_headers(ssdp_headers) - return ssdp_headers - - -async def init_ssdp_component(hass: HomeAssistant) -> SsdpListener: - """Initialize ssdp component and get SsdpListener.""" - await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) - await hass.async_block_till_done() - return hass.data[ssdp.DOMAIN][ssdp.SSDP_SCANNER]._ssdp_listeners[0] - - @patch( "homeassistant.components.ssdp.async_get_ssdp", return_value={"mock-domain": [{"st": "mock-st"}]}, @@ -481,7 +467,7 @@ async def test_discovery_from_advertisement_sets_ssdp_st( @patch( - "homeassistant.components.ssdp.async_build_source_set", + "homeassistant.components.ssdp.common.async_build_source_set", return_value={IPv4Address("192.168.1.1")}, ) async def test_start_stop_scanner(mock_source_set, hass: HomeAssistant) -> None: @@ -490,7 +476,7 @@ async def test_start_stop_scanner(mock_source_set, hass: HomeAssistant) -> None: hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + ssdp.SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + scanner.SCAN_INTERVAL) await hass.async_block_till_done() assert ssdp_listener.async_start.call_count == 1 assert ssdp_listener.async_search.call_count == 4 @@ -498,7 +484,7 @@ async def test_start_stop_scanner(mock_source_set, hass: HomeAssistant) -> None: hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + ssdp.SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + scanner.SCAN_INTERVAL) await hass.async_block_till_done() assert ssdp_listener.async_start.call_count == 1 assert ssdp_listener.async_search.call_count == 4 @@ -739,7 +725,7 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ }, ) @patch( - "homeassistant.components.ssdp.network.async_get_adapters", + "homeassistant.components.ssdp.common.network.async_get_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ) async def test_async_detect_interfaces_setting_empty_route( @@ -764,7 +750,7 @@ async def test_async_detect_interfaces_setting_empty_route( }, ) @patch( - "homeassistant.components.ssdp.network.async_get_adapters", + "homeassistant.components.ssdp.common.network.async_get_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ) async def test_bind_failure_skips_adapter( @@ -813,7 +799,7 @@ async def test_bind_failure_skips_adapter( }, ) @patch( - "homeassistant.components.ssdp.network.async_get_adapters", + "homeassistant.components.ssdp.common.network.async_get_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ) async def test_ipv4_does_additional_search_for_sonos( @@ -824,7 +810,7 @@ async def test_ipv4_does_additional_search_for_sonos( hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + ssdp.SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + scanner.SCAN_INTERVAL) await hass.async_block_till_done() assert ssdp_listener.async_search.call_count == 6 diff --git a/tests/components/ssdp/test_websocket_api.py b/tests/components/ssdp/test_websocket_api.py new file mode 100644 index 00000000000..eb71c33a690 --- /dev/null +++ b/tests/components/ssdp/test_websocket_api.py @@ -0,0 +1,147 @@ +"""The tests for the ssdp WebSocket API.""" + +import asyncio +from unittest.mock import ANY, AsyncMock, Mock, patch + +from homeassistant.core import EVENT_HOMEASSISTANT_STARTED, HomeAssistant + +from . import _ssdp_headers, init_ssdp_component + +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import WebSocketGenerator + + +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"deviceType": "Paulus"}]}, +) +async def test_subscribe_discovery( + mock_get_ssdp: Mock, + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_flow_init: AsyncMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test ssdp subscribe_discovery.""" + aioclient_mock.get( + "http://1.1.1.1", + text=""" + + + Paulus + Bedroom TV + + + """, + ) + ssdp_listener = await init_ssdp_component(hass) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + "_source": "search", + } + ) + ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done(wait_background_tasks=True) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "ssdp/subscribe_discovery", + } + ) + 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"] == [ + { + "ssdp_all_locations": ["http://1.1.1.1"], + "ssdp_ext": None, + "ssdp_headers": { + "_source": "search", + "_timestamp": ANY, + "_udn": "uuid:mock-udn", + "location": "http://1.1.1.1", + "st": "mock-st", + "usn": "uuid:mock-udn::mock-st", + }, + "ssdp_location": "http://1.1.1.1", + "ssdp_nt": None, + "ssdp_server": None, + "ssdp_st": "mock-st", + "ssdp_udn": "uuid:mock-udn", + "ssdp_usn": "uuid:mock-udn::mock-st", + "upnp": { + "UDN": "uuid:mock-udn", + "deviceType": "Paulus", + "friendlyName": "Bedroom TV", + }, + "name": "Bedroom TV", + "x_homeassistant_matching_domains": [], + } + ] + + mock_ssdp_advertisement = _ssdp_headers( + { + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + "nt": "upnp:rootdevice", + "nts": "ssdp:alive", + "_source": "advertisement", + } + ) + ssdp_listener._on_alive(mock_ssdp_advertisement) + await hass.async_block_till_done(wait_background_tasks=True) + + async with asyncio.timeout(1): + response = await client.receive_json() + + assert response["event"]["add"] == [ + { + "ssdp_all_locations": ["http://1.1.1.1"], + "ssdp_ext": None, + "ssdp_headers": { + "_source": "advertisement", + "_timestamp": ANY, + "_udn": "uuid:mock-udn", + "location": "http://1.1.1.1", + "nt": "upnp:rootdevice", + "nts": "ssdp:alive", + "usn": "uuid:mock-udn::mock-st", + }, + "ssdp_location": "http://1.1.1.1", + "ssdp_nt": "upnp:rootdevice", + "ssdp_server": None, + "ssdp_st": "upnp:rootdevice", + "ssdp_udn": "uuid:mock-udn", + "ssdp_usn": "uuid:mock-udn::mock-st", + "upnp": { + "UDN": "uuid:mock-udn", + "deviceType": "Paulus", + "friendlyName": "Bedroom TV", + }, + "name": "Bedroom TV", + "x_homeassistant_matching_domains": ["mock-domain"], + } + ] + + mock_ssdp_advertisement["nts"] = "ssdp:byebye" + ssdp_listener._on_byebye(mock_ssdp_advertisement) + await hass.async_block_till_done(wait_background_tasks=True) + + async with asyncio.timeout(1): + response = await client.receive_json() + + assert response["event"]["remove"] == [ + {"ssdp_location": "http://1.1.1.1", "ssdp_st": "upnp:rootdevice"} + ] diff --git a/tests/components/stiebel_eltron/__init__.py b/tests/components/stiebel_eltron/__init__.py new file mode 100644 index 00000000000..eaddd4c578b --- /dev/null +++ b/tests/components/stiebel_eltron/__init__.py @@ -0,0 +1 @@ +"""Tests for the STIEBEL ELTRON integration.""" diff --git a/tests/components/stiebel_eltron/conftest.py b/tests/components/stiebel_eltron/conftest.py new file mode 100644 index 00000000000..7ee2612efa7 --- /dev/null +++ b/tests/components/stiebel_eltron/conftest.py @@ -0,0 +1,55 @@ +"""Common fixtures for the STIEBEL ELTRON tests.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.stiebel_eltron import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_stiebel_eltron_client() -> Generator[MagicMock]: + """Mock a stiebel eltron client.""" + with ( + patch( + "homeassistant.components.stiebel_eltron.StiebelEltronAPI", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.stiebel_eltron.config_flow.StiebelEltronAPI", + new=mock_client, + ), + ): + client = mock_client.return_value + client.update.return_value = True + yield client + + +@pytest.fixture(autouse=True) +def mock_modbus() -> Generator[MagicMock]: + """Mock a modbus client.""" + with ( + patch( + "homeassistant.components.stiebel_eltron.ModbusTcpClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.stiebel_eltron.config_flow.ModbusTcpClient", + new=mock_client, + ), + ): + yield mock_client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Stiebel Eltron", + data={CONF_HOST: "1.1.1.1", CONF_PORT: 502}, + ) diff --git a/tests/components/stiebel_eltron/test_config_flow.py b/tests/components/stiebel_eltron/test_config_flow.py new file mode 100644 index 00000000000..278ab6eea6f --- /dev/null +++ b/tests/components/stiebel_eltron/test_config_flow.py @@ -0,0 +1,209 @@ +"""Test the STIEBEL ELTRON config flow.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.stiebel_eltron.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_stiebel_eltron_client") +async def test_full_flow(hass: HomeAssistant) -> 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_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Stiebel Eltron" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + } + + +async def test_form_cannot_connect( + hass: HomeAssistant, + mock_stiebel_eltron_client: MagicMock, +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_stiebel_eltron_client.update.return_value = False + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_stiebel_eltron_client.update.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_form_unknown_exception( + hass: HomeAssistant, + mock_stiebel_eltron_client: MagicMock, +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_stiebel_eltron_client.update.side_effect = Exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + mock_stiebel_eltron_client.update.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we handle already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_stiebel_eltron_client") +async def test_import(hass: HomeAssistant) -> None: + """Test import step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + CONF_NAME: "Stiebel Eltron", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Stiebel Eltron" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + } + + +async def test_import_cannot_connect( + hass: HomeAssistant, + mock_stiebel_eltron_client: MagicMock, +) -> None: + """Test we handle cannot connect error.""" + mock_stiebel_eltron_client.update.return_value = False + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + CONF_NAME: "Stiebel Eltron", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_unknown_exception( + hass: HomeAssistant, + mock_stiebel_eltron_client: MagicMock, +) -> None: + """Test we handle cannot connect error.""" + mock_stiebel_eltron_client.update.side_effect = Exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + CONF_NAME: "Stiebel Eltron", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_import_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we handle already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + CONF_NAME: "Stiebel Eltron", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/stiebel_eltron/test_init.py b/tests/components/stiebel_eltron/test_init.py new file mode 100644 index 00000000000..f8413c41461 --- /dev/null +++ b/tests/components/stiebel_eltron/test_init.py @@ -0,0 +1,177 @@ +"""Tests for the STIEBEL ELTRON integration.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.stiebel_eltron.const import CONF_HUB, DEFAULT_HUB, DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@pytest.mark.usefixtures("mock_stiebel_eltron_client") +async def test_async_setup_success( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test successful async_setup.""" + config = { + DOMAIN: { + CONF_NAME: "Stiebel Eltron", + CONF_HUB: DEFAULT_HUB, + }, + "modbus": [ + { + CONF_NAME: DEFAULT_HUB, + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + } + ], + } + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Verify the issue is created + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") + assert issue + assert issue.active is True + assert issue.severity == ir.IssueSeverity.WARNING + + +@pytest.mark.usefixtures("mock_stiebel_eltron_client") +async def test_async_setup_already_configured( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_config_entry, +) -> None: + """Test we handle already configured.""" + mock_config_entry.add_to_hass(hass) + + config = { + DOMAIN: { + CONF_NAME: "Stiebel Eltron", + CONF_HUB: DEFAULT_HUB, + }, + "modbus": [ + { + CONF_NAME: DEFAULT_HUB, + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + } + ], + } + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Verify the issue is created + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") + assert issue + assert issue.active is True + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_async_setup_with_non_existing_hub( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test async_setup with non-existing modbus hub.""" + config = { + DOMAIN: { + CONF_NAME: "Stiebel Eltron", + CONF_HUB: "non_existing_hub", + }, + } + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Verify the issue is created + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_missing_hub" + ) + assert issue + assert issue.active is True + assert issue.is_fixable is False + assert issue.is_persistent is False + assert issue.translation_key == "deprecated_yaml_import_issue_missing_hub" + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_async_setup_import_failure( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_stiebel_eltron_client: AsyncMock, +) -> None: + """Test async_setup with import failure.""" + config = { + DOMAIN: { + CONF_NAME: "Stiebel Eltron", + CONF_HUB: DEFAULT_HUB, + }, + "modbus": [ + { + CONF_NAME: DEFAULT_HUB, + CONF_HOST: "invalid_host", + CONF_PORT: 502, + } + ], + } + + # Simulate an import failure + mock_stiebel_eltron_client.update.side_effect = Exception("Import failure") + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Verify the issue is created + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_unknown" + ) + assert issue + assert issue.active is True + assert issue.is_fixable is False + assert issue.is_persistent is False + assert issue.translation_key == "deprecated_yaml_import_issue_unknown" + assert issue.severity == ir.IssueSeverity.WARNING + + +@pytest.mark.usefixtures("mock_modbus") +async def test_async_setup_cannot_connect( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_stiebel_eltron_client: AsyncMock, +) -> None: + """Test async_setup with import failure.""" + config = { + DOMAIN: { + CONF_NAME: "Stiebel Eltron", + CONF_HUB: DEFAULT_HUB, + }, + "modbus": [ + { + CONF_NAME: DEFAULT_HUB, + CONF_HOST: "invalid_host", + CONF_PORT: 502, + } + ], + } + + # Simulate a cannot connect error + mock_stiebel_eltron_client.update.return_value = False + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Verify the issue is created + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_cannot_connect" + ) + assert issue + assert issue.active is True + assert issue.is_fixable is False + assert issue.is_persistent is False + assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" + assert issue.severity == ir.IssueSeverity.WARNING diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index a7aeae25ac7..ec848c61338 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -27,12 +27,10 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture(autouse=True) -def setup_comp(hass: HomeAssistant) -> None: +async def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" mock_component(hass, "group") - hass.loop.run_until_complete( - async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) - ) + await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) async def test_sunset_trigger( diff --git a/tests/components/surepetcare/__init__.py b/tests/components/surepetcare/__init__.py index 9bf84889368..c34e3ecc923 100644 --- a/tests/components/surepetcare/__init__.py +++ b/tests/components/surepetcare/__init__.py @@ -8,7 +8,11 @@ MOCK_HUB = { "product_id": 1, "household_id": HOUSEHOLD_ID, "name": "Hub", - "status": {"online": True, "led_mode": 0, "pairing_mode": 0}, + "status": { + "led_mode": 0, + "pairing_mode": 0, + "online": True, + }, } MOCK_FEEDER = { @@ -22,6 +26,7 @@ MOCK_FEEDER = { "locking": {"mode": 0}, "learn_mode": 0, "signal": {"device_rssi": 60, "hub_rssi": 65}, + "online": True, }, } diff --git a/tests/components/switch_as_x/test_config_flow.py b/tests/components/switch_as_x/test_config_flow.py index 2da4c52c7f9..a371cdea63b 100644 --- a/tests/components/switch_as_x/test_config_flow.py +++ b/tests/components/switch_as_x/test_config_flow.py @@ -20,7 +20,7 @@ from homeassistant.helpers import entity_registry as er from . import PLATFORMS_TO_TEST, STATE_MAP -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) @@ -160,9 +160,7 @@ async def test_options( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - schema = result["data_schema"].schema - schema_key = next(k for k in schema if k == CONF_INVERT) - assert schema_key.description["suggested_value"] is True + assert get_schema_suggested_value(result["data_schema"].schema, CONF_INVERT) is True result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 715073aa891..941d58c8e3a 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -386,3 +386,172 @@ def make_advertisement( connectable=True, tx_power=-127, ) + + +HUBMINI_MATTER_SERVICE_INFO = BluetoothServiceInfoBleak( + name="HubMini Matter", + manufacturer_data={ + 2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"%\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="HubMini Matter", + manufacturer_data={ + 2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"v\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "HubMini Matter"), + time=0, + connectable=True, + tx_power=-127, +) + + +ROLLER_SHADE_SERVICE_INFO = BluetoothServiceInfoBleak( + name="RollerShade", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeT\x90\x1b,\x08\x9f\x11\x04'\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b",\x00'\x9f\x11\x04"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="RollerShade", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeT\x90\x1b,\x08\x9f\x11\x04'\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b",\x00'\x9f\x11\x04"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "RollerShade"), + time=0, + connectable=True, + tx_power=-127, +) + + +HUMIDIFIER_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Humidifier", + manufacturer_data={ + 741: b"\xacg\xb2\xcd\xfa\xbe", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"e\x80\x00\xf9\x80Bc\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Humidifier", + manufacturer_data={ + 741: b"\xacg\xb2\xcd\xfa\xbe", + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"e\x80\x00\xf9\x80Bc\x00" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Humidifier"), + time=0, + connectable=True, + tx_power=-127, +) + + +WOSTRIP_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoStrip", + address="AA:BB:CC:DD:EE:FF", + manufacturer_data={ + 2409: b'\x84\xf7\x03\xb3?\xde\x04\xe4"\x0c\x00\x00\x00\x00\x00\x00' + }, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"r\x00d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="WoStrip", + manufacturer_data={ + 2409: b'\x84\xf7\x03\xb3?\xde\x04\xe4"\x0c\x00\x00\x00\x00\x00\x00' + }, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"r\x00d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoStrip"), + time=0, + connectable=True, + tx_power=-127, +) + + +WOLOCKPRO_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoLockPro", + manufacturer_data={2409: b"\xf7a\x07H\xe6\xe8-\x80\x00d\x00\x08"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"$\x80d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="WoLockPro", + manufacturer_data={2409: b"\xf7a\x07H\xe6\xe8-\x80\x00d\x00\x08"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"$\x80d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoLockPro"), + time=0, + connectable=True, + tx_power=-127, +) + + +LOCK_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoLock", + manufacturer_data={2409: b"\xca\xbaP\xddv;\x03\x03\x00 "}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"o\x80d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="WoLock", + manufacturer_data={2409: b"\xca\xbaP\xddv;\x03\x03\x00 "}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"o\x80d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoLock"), + time=0, + connectable=True, + tx_power=-127, +) + + +CIRCULATOR_FAN_SERVICE_INFO = BluetoothServiceInfoBleak( + name="CirculatorFan", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeXY\xa8~LR9", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"~\x00R"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="CirculatorFan", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeXY\xa8~LR9", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"~\x00R"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "CirculatorFan"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/conftest.py b/tests/components/switchbot/conftest.py index aff94626a68..45bd069e9bd 100644 --- a/tests/components/switchbot/conftest.py +++ b/tests/components/switchbot/conftest.py @@ -2,7 +2,11 @@ import pytest -from homeassistant.components.switchbot.const import DOMAIN +from homeassistant.components.switchbot.const import ( + CONF_ENCRYPTION_KEY, + CONF_KEY_ID, + DOMAIN, +) from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_SENSOR_TYPE from tests.common import MockConfigEntry @@ -25,3 +29,19 @@ def mock_entry_factory(): }, unique_id="aabbccddeeff", ) + + +@pytest.fixture +def mock_entry_encrypted_factory(): + """Fixture to create a MockConfigEntry with an encryption key and a customizable sensor type.""" + return lambda sensor_type="lock": MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: sensor_type, + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + unique_id="aabbccddeeff", + ) diff --git a/tests/components/switchbot/snapshots/test_diagnostics.ambr b/tests/components/switchbot/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..e9cdfe3152c --- /dev/null +++ b/tests/components/switchbot/snapshots/test_diagnostics.ambr @@ -0,0 +1,82 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'entry': dict({ + 'data': dict({ + 'address': 'aa:bb:cc:dd:ee:ff', + 'encryption_key': '**REDACTED**', + 'key_id': '**REDACTED**', + 'name': 'test-name', + 'sensor_type': 'relay_switch_1pm', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'switchbot', + 'minor_version': 1, + 'options': dict({ + 'retry_count': 3, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': 'aabbccddeeaa', + 'version': 1, + }), + 'service_info': dict({ + 'address': 'AA:BB:CC:DD:EE:FF', + 'advertisement': list([ + 'W1080000', + dict({ + '2409': dict({ + '__type': "", + 'repr': "b'$X|\\x0866G\\x81\\x00\\x00\\x001\\x00\\x00\\x00\\x00'", + }), + }), + dict({ + '0000fd3d-0000-1000-8000-00805f9b34fb': dict({ + '__type': "", + 'repr': "b'<\\x00\\x00\\x00'", + }), + }), + list([ + 'cba20d00-224d-11e6-9fb8-0002a5d5c51b', + ]), + -127, + -60, + list([ + list([ + ]), + ]), + ]), + 'connectable': True, + 'device': dict({ + '__type': "", + 'repr': 'BLEDevice(AA:BB:CC:DD:EE:FF, W1080000)', + }), + 'manufacturer_data': dict({ + '2409': dict({ + '__type': "", + 'repr': "b'$X|\\x0866G\\x81\\x00\\x00\\x001\\x00\\x00\\x00\\x00'", + }), + }), + 'name': 'W1080000', + 'raw': None, + 'rssi': -60, + 'service_data': dict({ + '0000fd3d-0000-1000-8000-00805f9b34fb': dict({ + '__type': "", + 'repr': "b'<\\x00\\x00\\x00'", + }), + }), + 'service_uuids': list([ + 'cba20d00-224d-11e6-9fb8-0002a5d5c51b', + ]), + 'source': 'local', + 'tx_power': -127, + }), + }) +# --- diff --git a/tests/components/switchbot/test_cover.py b/tests/components/switchbot/test_cover.py index 8810963f63d..b52436f1932 100644 --- a/tests/components/switchbot/test_cover.py +++ b/tests/components/switchbot/test_cover.py @@ -24,7 +24,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State -from . import WOBLINDTILT_SERVICE_INFO, WOCURTAIN3_SERVICE_INFO, make_advertisement +from . import ( + ROLLER_SHADE_SERVICE_INFO, + WOBLINDTILT_SERVICE_INFO, + WOCURTAIN3_SERVICE_INFO, + make_advertisement, +) from tests.common import MockConfigEntry, mock_restore_cache from tests.components.bluetooth import inject_bluetooth_service_info @@ -325,3 +330,163 @@ async def test_blindtilt_controlling( state = hass.states.get(entity_id) assert state.state == CoverState.OPEN assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 + + +async def test_roller_shade_setup( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test setting up the RollerShade.""" + inject_bluetooth_service_info(hass, WOCURTAIN3_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="roller_shade") + + entity_id = "cover.test_name" + mock_restore_cache( + hass, + [ + State( + entity_id, + CoverState.OPEN, + {ATTR_CURRENT_POSITION: 60}, + ) + ], + ) + + entry.add_to_hass(hass) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.update", + new=AsyncMock(return_value=True), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + + +async def test_roller_shade_controlling( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test Roller Shade controlling.""" + inject_bluetooth_service_info(hass, ROLLER_SHADE_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="roller_shade") + entry.add_to_hass(hass) + info = {"battery": 39} + with ( + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + new=AsyncMock(return_value=info), + ), + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.open", + new=AsyncMock(return_value=True), + ) as mock_open, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.close", + new=AsyncMock(return_value=True), + ) as mock_close, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.stop", + new=AsyncMock(return_value=True), + ) as mock_stop, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.set_position", + new=AsyncMock(return_value=True), + ) as mock_set_position, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + address = "AA:BB:CC:DD:EE:FF" + service_data = b",\x00'\x9f\x11\x04" + + # Test open + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\xa0\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + new=AsyncMock(return_value=info), + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_open.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 68 + + # Test close + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x5a\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_close.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 10 + + # Test stop + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x5f\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_stop.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 5 + + # Test set position + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x32\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_set_position.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 50 diff --git a/tests/components/switchbot/test_diagnostics.py b/tests/components/switchbot/test_diagnostics.py new file mode 100644 index 00000000000..e5974459e09 --- /dev/null +++ b/tests/components/switchbot/test_diagnostics.py @@ -0,0 +1,63 @@ +"""Tests for the diagnostics data provided by the Switchbot integration.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.switchbot.const import ( + CONF_ENCRYPTION_KEY, + CONF_KEY_ID, + CONF_RETRY_COUNT, + DEFAULT_RETRY_COUNT, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_SENSOR_TYPE +from homeassistant.core import HomeAssistant + +from . import WORELAY_SWITCH_1PM_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + + inject_bluetooth_service_info(hass, WORELAY_SWITCH_1PM_SERVICE_INFO) + + with patch( + "homeassistant.components.switchbot.switch.switchbot.SwitchbotRelaySwitch.update", + return_value=None, + ): + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "relay_switch_1pm", + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + unique_id="aabbccddeeaa", + options={CONF_RETRY_COUNT: DEFAULT_RETRY_COUNT}, + ) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + assert result == snapshot( + exclude=props("created_at", "modified_at", "entry_id", "time") + ) diff --git a/tests/components/switchbot/test_fan.py b/tests/components/switchbot/test_fan.py new file mode 100644 index 00000000000..815d3aceda3 --- /dev/null +++ b/tests/components/switchbot/test_fan.py @@ -0,0 +1,91 @@ +"""Test the switchbot fan.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.fan import ( + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DOMAIN as FAN_DOMAIN, + SERVICE_OSCILLATE, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant + +from . import CIRCULATOR_FAN_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ( + "service", + "service_data", + "mock_method", + ), + [ + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: "baby"}, + "set_preset_mode", + ), + ( + SERVICE_SET_PERCENTAGE, + {ATTR_PERCENTAGE: 27}, + "set_percentage", + ), + ( + SERVICE_OSCILLATE, + {ATTR_OSCILLATING: True}, + "set_oscillation", + ), + ( + SERVICE_TURN_OFF, + {}, + "turn_off", + ), + ( + SERVICE_TURN_ON, + {}, + "turn_on", + ), + ], +) +async def test_circulator_fan_controlling( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, +) -> None: + """Test controlling the circulator fan with different services.""" + inject_bluetooth_service_info(hass, CIRCULATOR_FAN_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="circulator_fan") + entity_id = "fan.test_name" + entry.add_to_hass(hass) + + mocked_instance = AsyncMock(return_value=True) + mcoked_none_instance = AsyncMock(return_value=None) + with patch.multiple( + "homeassistant.components.switchbot.fan.switchbot.SwitchbotFan", + get_basic_info=mcoked_none_instance, + **{mock_method: mocked_instance}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + FAN_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once() diff --git a/tests/components/switchbot/test_humidifier.py b/tests/components/switchbot/test_humidifier.py new file mode 100644 index 00000000000..cb2882a7475 --- /dev/null +++ b/tests/components/switchbot/test_humidifier.py @@ -0,0 +1,123 @@ +"""Test the switchbot humidifiers.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + ATTR_MODE, + DOMAIN as HUMIDIFIER_DOMAIN, + MODE_AUTO, + MODE_NORMAL, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from . import HUMIDIFIER_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ( + "service", + "service_data", + "mock_method", + "expected_args", + ), + [ + ( + SERVICE_TURN_OFF, + {}, + "turn_off", + (), + ), + ( + SERVICE_TURN_ON, + {}, + "turn_on", + (), + ), + ( + SERVICE_SET_HUMIDITY, + {ATTR_HUMIDITY: 50}, + "set_humidity_level", + (50,), + ), + ( + SERVICE_SET_MODE, + {ATTR_MODE: MODE_AUTO}, + "set_auto_mode", + (), + ), + ( + SERVICE_SET_MODE, + {ATTR_MODE: MODE_NORMAL}, + "set_manual_mode", + (), + ), + ], +) +async def test_humidifier_services( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + expected_args: tuple, +) -> None: + """Test all humidifier services with proper parameters.""" + inject_bluetooth_service_info(hass, HUMIDIFIER_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="humidifier") + entry.add_to_hass(hass) + entity_id = "humidifier.test_name" + + with ( + patch( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotHumidifier.set_level", + new=AsyncMock(return_value=True), + ) as mock_set_humidity_level, + patch( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotHumidifier.async_set_auto", + new=AsyncMock(return_value=True), + ) as mock_set_auto_mode, + patch( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotHumidifier.async_set_manual", + new=AsyncMock(return_value=True), + ) as mock_set_manual_mode, + patch( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotHumidifier.turn_off", + new=AsyncMock(return_value=True), + ) as mock_turn_off, + patch( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotHumidifier.turn_on", + new=AsyncMock(return_value=True), + ) as mock_turn_on, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_map = { + "turn_off": mock_turn_off, + "turn_on": mock_turn_on, + "set_humidity_level": mock_set_humidity_level, + "set_auto_mode": mock_set_auto_mode, + "set_manual_mode": mock_set_manual_mode, + } + mock_instance = mock_map[mock_method] + mock_instance.assert_awaited_once_with(*expected_args) diff --git a/tests/components/switchbot/test_light.py b/tests/components/switchbot/test_light.py new file mode 100644 index 00000000000..ef46017e9ae --- /dev/null +++ b/tests/components/switchbot/test_light.py @@ -0,0 +1,139 @@ +"""Test the switchbot lights.""" + +from collections.abc import Callable +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from switchbot import ColorMode as switchbotColorMode + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from . import WOSTRIP_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ( + "service", + "service_data", + "mock_method", + "expected_args", + "color_modes", + "color_mode", + ), + [ + ( + SERVICE_TURN_OFF, + {}, + "turn_off", + (), + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {}, + "turn_on", + (), + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 128}, + "set_brightness", + (round(128 / 255 * 100),), + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_RGB_COLOR: (255, 0, 0)}, + "set_rgb", + (round(255 / 255 * 100), 255, 0, 0), + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_COLOR_TEMP_KELVIN: 4000}, + "set_color_temp", + (100, 4000), + {switchbotColorMode.COLOR_TEMP}, + switchbotColorMode.COLOR_TEMP, + ), + ], +) +async def test_light_strip_services( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, + color_modes: set | None, + color_mode: switchbotColorMode | None, +) -> None: + """Test all SwitchBot light strip services with proper parameters.""" + inject_bluetooth_service_info(hass, WOSTRIP_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="light_strip") + entry.add_to_hass(hass) + entity_id = "light.test_name" + + with ( + patch("switchbot.SwitchbotLightStrip.color_modes", new=color_modes), + patch("switchbot.SwitchbotLightStrip.color_mode", new=color_mode), + patch( + "switchbot.SwitchbotLightStrip.turn_on", + new=AsyncMock(return_value=True), + ) as mock_turn_on, + patch( + "switchbot.SwitchbotLightStrip.turn_off", + new=AsyncMock(return_value=True), + ) as mock_turn_off, + patch( + "switchbot.SwitchbotLightStrip.set_brightness", + new=AsyncMock(return_value=True), + ) as mock_set_brightness, + patch( + "switchbot.SwitchbotLightStrip.set_rgb", + new=AsyncMock(return_value=True), + ) as mock_set_rgb, + patch( + "switchbot.SwitchbotLightStrip.set_color_temp", + new=AsyncMock(return_value=True), + ) as mock_set_color_temp, + patch("switchbot.SwitchbotLightStrip.update", new=AsyncMock(return_value=None)), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_map = { + "turn_off": mock_turn_off, + "turn_on": mock_turn_on, + "set_brightness": mock_set_brightness, + "set_rgb": mock_set_rgb, + "set_color_temp": mock_set_color_temp, + } + mock_instance = mock_map[mock_method] + mock_instance.assert_awaited_once_with(*expected_args) diff --git a/tests/components/switchbot/test_lock.py b/tests/components/switchbot/test_lock.py new file mode 100644 index 00000000000..b7153a041d0 --- /dev/null +++ b/tests/components/switchbot/test_lock.py @@ -0,0 +1,105 @@ +"""Test the switchbot locks.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_LOCK, + SERVICE_OPEN, + SERVICE_UNLOCK, +) +from homeassistant.core import HomeAssistant + +from . import LOCK_SERVICE_INFO, WOLOCKPRO_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ("sensor_type", "service_info"), + [("lock_pro", WOLOCKPRO_SERVICE_INFO), ("lock", LOCK_SERVICE_INFO)], +) +@pytest.mark.parametrize( + ("service", "mock_method"), + [(SERVICE_UNLOCK, "unlock"), (SERVICE_LOCK, "lock")], +) +async def test_lock_services( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service: str, + mock_method: str, + service_info: BluetoothServiceInfoBleak, +) -> None: + """Test lock and unlock services on lock and lockpro devices.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_encrypted_factory(sensor_type=sensor_type) + entry.add_to_hass(hass) + + with patch( + f"homeassistant.components.switchbot.lock.switchbot.SwitchbotLock.{mock_method}", + ) as mocked_instance: + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "lock.test_name" + + await hass.services.async_call( + LOCK_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once() + + +@pytest.mark.parametrize( + ("sensor_type", "service_info"), + [("lock_pro", WOLOCKPRO_SERVICE_INFO), ("lock", LOCK_SERVICE_INFO)], +) +@pytest.mark.parametrize( + ("service", "mock_method"), + [(SERVICE_UNLOCK, "unlock_without_unlatch"), (SERVICE_OPEN, "unlock")], +) +async def test_lock_services_with_night_latch_enabled( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service: str, + mock_method: str, + service_info: BluetoothServiceInfoBleak, +) -> None: + """Test lock service when night latch enabled.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_encrypted_factory(sensor_type=sensor_type) + entry.add_to_hass(hass) + + mocked_instance = AsyncMock(return_value=True) + + with patch.multiple( + "homeassistant.components.switchbot.lock.switchbot.SwitchbotLock", + is_night_latch_enabled=MagicMock(return_value=True), + **{mock_method: mocked_instance}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "lock.test_name" + + await hass.services.async_call( + LOCK_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once() diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index 5fd270b3393..8b1e6c83f21 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -22,6 +22,8 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import ( + CIRCULATOR_FAN_SERVICE_INFO, + HUBMINI_MATTER_SERVICE_INFO, LEAK_SERVICE_INFO, REMOTE_SERVICE_INFO, WOHAND_SERVICE_INFO, @@ -293,3 +295,93 @@ async def test_hub2_sensor(hass: HomeAssistant) -> None: 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_hubmini_matter_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the sensor for HubMini Matter.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, HUBMINI_MATTER_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "hubmini_matter", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 3 + + temperature_sensor = hass.states.get("sensor.test_name_temperature") + temperature_sensor_attrs = temperature_sensor.attributes + assert temperature_sensor.state == "24.1" + assert temperature_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Temperature" + assert temperature_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temperature_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + humidity_sensor = hass.states.get("sensor.test_name_humidity") + humidity_sensor_attrs = humidity_sensor.attributes + assert humidity_sensor.state == "53" + assert humidity_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Humidity" + assert humidity_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert humidity_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + 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_fan_sensors(hass: HomeAssistant) -> None: + """Test setting up creates the sensors.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, CIRCULATOR_FAN_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_SENSOR_TYPE: "circulator_fan", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.switchbot.fan.switchbot.SwitchbotFan.update", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 2 + + battery_sensor = hass.states.get("sensor.test_name_battery") + battery_sensor_attrs = battery_sensor.attributes + assert battery_sensor.state == "82" + assert battery_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Battery" + assert battery_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert battery_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/switchbot/test_switch.py b/tests/components/switchbot/test_switch.py new file mode 100644 index 00000000000..2d572fd9996 --- /dev/null +++ b/tests/components/switchbot/test_switch.py @@ -0,0 +1,47 @@ +"""Test the switchbot switches.""" + +from collections.abc import Callable +from unittest.mock import patch + +from homeassistant.components.switch import STATE_ON +from homeassistant.core import HomeAssistant, State + +from . import WOHAND_SERVICE_INFO + +from tests.common import MockConfigEntry, mock_restore_cache +from tests.components.bluetooth import inject_bluetooth_service_info + + +async def test_switchbot_switch_with_restore_state( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], +) -> None: + """Test that Switchbot Switch restores state correctly after reboot.""" + inject_bluetooth_service_info(hass, WOHAND_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="bot") + entity_id = "switch.test_name" + + mock_restore_cache( + hass, + [ + State( + entity_id, + STATE_ON, + {"last_run_success": True}, + ) + ], + ) + + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.switchbot.switch.switchbot.Switchbot.switch_mode", + return_value=False, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes["last_run_success"] is True diff --git a/tests/components/switchbot_cloud/test_button.py b/tests/components/switchbot_cloud/test_button.py index 0779e54ee03..8c74709fdf5 100644 --- a/tests/components/switchbot_cloud/test_button.py +++ b/tests/components/switchbot_cloud/test_button.py @@ -19,6 +19,7 @@ async def test_pressmode_bot( """Test press.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="bot-id-1", deviceName="bot-1", deviceType="Bot", @@ -51,6 +52,7 @@ async def test_switchmode_bot_no_button_entity( """Test a switchMode bot isn't added as a button.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="bot-id-1", deviceName="bot-1", deviceType="Bot", diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index f4837c4e97e..b2d1cff6679 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -33,30 +33,35 @@ async def test_setup_entry_success( """Test successful setup of entry.""" mock_list_devices.return_value = [ Remote( + version="V1.0", deviceId="air-conditonner-id-1", deviceName="air-conditonner-name-1", remoteType="Air Conditioner", hubDeviceId="test-hub-id", ), Device( + version="V1.0", deviceId="plug-id-1", deviceName="plug-name-1", deviceType="Plug", hubDeviceId="test-hub-id", ), Remote( + version="V1.0", deviceId="plug-id-2", deviceName="plug-name-2", remoteType="DIY Plug", hubDeviceId="test-hub-id", ), Remote( + version="V1.0", deviceId="meter-pro-1", deviceName="meter-pro-name-1", deviceType="MeterPro(CO2)", hubDeviceId="test-hub-id", ), Remote( + version="V1.0", deviceId="hub2-1", deviceName="hub2-name-1", deviceType="Hub 2", @@ -104,6 +109,7 @@ async def test_setup_entry_fails_when_refreshing( """Test error handling in get_status in setup of entry.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="test-id", deviceName="test-name", deviceType="Plug", diff --git a/tests/components/switchbot_cloud/test_lock.py b/tests/components/switchbot_cloud/test_lock.py index fcb81abfc51..ca41f6eb99f 100644 --- a/tests/components/switchbot_cloud/test_lock.py +++ b/tests/components/switchbot_cloud/test_lock.py @@ -17,6 +17,7 @@ async def test_lock(hass: HomeAssistant, mock_list_devices, mock_get_status) -> """Test locking and unlocking.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="lock-id-1", deviceName="lock-1", deviceType="Smart Lock", diff --git a/tests/components/switchbot_cloud/test_sensor.py b/tests/components/switchbot_cloud/test_sensor.py index 6b0a52800f3..1008dd72b47 100644 --- a/tests/components/switchbot_cloud/test_sensor.py +++ b/tests/components/switchbot_cloud/test_sensor.py @@ -26,6 +26,7 @@ async def test_meter( mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="meter-id-1", deviceName="meter-1", deviceType="Meter", @@ -50,6 +51,7 @@ async def test_meter_no_coordinator_data( """Test meter sensors are unknown without coordinator data.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="meter-id-1", deviceName="meter-1", deviceType="Meter", diff --git a/tests/components/switchbot_cloud/test_switch.py b/tests/components/switchbot_cloud/test_switch.py index 99e0f50aa53..9bd93342bae 100644 --- a/tests/components/switchbot_cloud/test_switch.py +++ b/tests/components/switchbot_cloud/test_switch.py @@ -25,6 +25,7 @@ async def test_relay_switch( """Test turn on and turn off.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="relay-switch-id-1", deviceName="relay-switch-1", deviceType="Relay Switch 1", @@ -59,6 +60,7 @@ async def test_switchmode_bot( """Test turn on and turn off.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="bot-id-1", deviceName="bot-1", deviceType="Bot", @@ -93,6 +95,7 @@ async def test_pressmode_bot_no_switch_entity( """Test a pressMode bot isn't added as a switch.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="bot-id-1", deviceName="bot-1", deviceType="Bot", diff --git a/tests/components/switcher_kis/test_button.py b/tests/components/switcher_kis/test_button.py index 6ebd82363e4..bf48647176b 100644 --- a/tests/components/switcher_kis/test_button.py +++ b/tests/components/switcher_kis/test_button.py @@ -2,7 +2,8 @@ from unittest.mock import ANY, patch -from aioswitcher.api import DeviceState, SwitcherBaseResponse, ThermostatSwing +from aioswitcher.api.messages import SwitcherBaseResponse +from aioswitcher.device import DeviceState, ThermostatSwing import pytest from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS diff --git a/tests/components/switcher_kis/test_climate.py b/tests/components/switcher_kis/test_climate.py index 72a25d20d04..426c52640c1 100644 --- a/tests/components/switcher_kis/test_climate.py +++ b/tests/components/switcher_kis/test_climate.py @@ -2,7 +2,7 @@ from unittest.mock import ANY, patch -from aioswitcher.api import SwitcherBaseResponse +from aioswitcher.api.messages import SwitcherBaseResponse from aioswitcher.device import ( DeviceState, ThermostatFanLevel, diff --git a/tests/components/switcher_kis/test_cover.py b/tests/components/switcher_kis/test_cover.py index 5829d6345ef..767389a3352 100644 --- a/tests/components/switcher_kis/test_cover.py +++ b/tests/components/switcher_kis/test_cover.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from aioswitcher.api import SwitcherBaseResponse +from aioswitcher.api.messages import SwitcherBaseResponse from aioswitcher.device import ShutterDirection import pytest diff --git a/tests/components/switcher_kis/test_init.py b/tests/components/switcher_kis/test_init.py index a652348463e..afef28dec7b 100644 --- a/tests/components/switcher_kis/test_init.py +++ b/tests/components/switcher_kis/test_init.py @@ -2,6 +2,7 @@ from datetime import timedelta +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.switcher_kis.const import DOMAIN, MAX_UPDATE_INTERVAL_SEC @@ -20,7 +21,10 @@ from tests.typing import WebSocketGenerator async def test_update_fail( - hass: HomeAssistant, mock_bridge, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mock_bridge, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test entities state unavailable when updates fail..""" entry = await init_integration(hass) @@ -32,9 +36,8 @@ async def test_update_fail( assert mock_bridge.is_running is True assert len(entry.runtime_data) == 2 - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=MAX_UPDATE_INTERVAL_SEC + 1) - ) + freezer.tick(timedelta(seconds=MAX_UPDATE_INTERVAL_SEC + 1)) + async_fire_time_changed(hass) await hass.async_block_till_done() for device in DUMMY_SWITCHER_DEVICES: @@ -84,7 +87,10 @@ async def test_entry_unload(hass: HomeAssistant, mock_bridge) -> None: async def test_remove_device( - hass: HomeAssistant, mock_bridge, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + mock_bridge, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, ) -> None: """Test being able to remove a disconnected device.""" assert await async_setup_component(hass, "config", {}) @@ -98,7 +104,6 @@ async def test_remove_device( assert mock_bridge.is_running is True assert len(entry.runtime_data) == 2 - device_registry = dr.async_get(hass) live_device_id = DUMMY_DEVICE_ID1 dead_device_id = DUMMY_DEVICE_ID4 diff --git a/tests/components/switcher_kis/test_light.py b/tests/components/switcher_kis/test_light.py index 51d0eb6332f..715110fb02b 100644 --- a/tests/components/switcher_kis/test_light.py +++ b/tests/components/switcher_kis/test_light.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from aioswitcher.api import SwitcherBaseResponse +from aioswitcher.api.messages import SwitcherBaseResponse from aioswitcher.device import DeviceState import pytest @@ -111,6 +111,44 @@ async def test_light( assert state.state == STATE_OFF +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_light_ignore_previous_async_state( + hass: HomeAssistant, mock_bridge, mock_api +) -> None: + """Test light ignores previous async state.""" + await init_integration(hass, USERNAME, TOKEN) + assert mock_bridge + + entity_id = f"{LIGHT_DOMAIN}.{slugify(DEVICE.name)}_light_1" + + # Test initial state - light on + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test turning off light + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_light" + ) as mock_set_light: + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + # Push old state and makge sure it is ignored + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 2 + mock_set_light.assert_called_once_with(DeviceState.OFF, 0) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Verify new state is not ignored + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + @pytest.mark.parametrize( ("device", "entity_id", "light_id", "device_state"), [ @@ -133,7 +171,6 @@ async def test_light_control_fail( mock_bridge, mock_api, monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, device, entity_id: str, light_id: int, diff --git a/tests/components/switcher_kis/test_sensor.py b/tests/components/switcher_kis/test_sensor.py index f99d91bd9a3..1a6c2ccb687 100644 --- a/tests/components/switcher_kis/test_sensor.py +++ b/tests/components/switcher_kis/test_sensor.py @@ -3,7 +3,6 @@ import pytest from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify from . import init_integration @@ -33,7 +32,7 @@ DEVICE_SENSORS_TUPLE = ( ( DUMMY_THERMOSTAT_DEVICE, [ - ("current_temperature", "temperature"), + ("temperature", "temperature"), ], ), ) @@ -55,35 +54,6 @@ async def test_sensor_platform(hass: HomeAssistant, mock_bridge) -> None: assert state.state == str(getattr(device, field)) -async def test_sensor_disabled( - hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_bridge -) -> None: - """Test sensor disabled by default.""" - await init_integration(hass) - assert mock_bridge - - mock_bridge.mock_callbacks([DUMMY_WATER_HEATER_DEVICE]) - await hass.async_block_till_done() - - device = DUMMY_WATER_HEATER_DEVICE - unique_id = f"{device.device_id}-{device.mac_address}-auto_off_set" - entity_id = f"sensor.{slugify(device.name)}_auto_shutdown" - entry = entity_registry.async_get(entity_id) - - assert entry - assert entry.unique_id == unique_id - assert entry.disabled is True - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Test enabling entity - updated_entry = entity_registry.async_update_entity( - entry.entity_id, disabled_by=None - ) - - assert updated_entry != entry - assert updated_entry.disabled is False - - @pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) async def test_sensor_update( hass: HomeAssistant, mock_bridge, monkeypatch: pytest.MonkeyPatch diff --git a/tests/components/switcher_kis/test_switch.py b/tests/components/switcher_kis/test_switch.py index c20149de074..52391f4dd08 100644 --- a/tests/components/switcher_kis/test_switch.py +++ b/tests/components/switcher_kis/test_switch.py @@ -2,8 +2,9 @@ from unittest.mock import patch -from aioswitcher.api import Command, ShutterChildLock, SwitcherBaseResponse -from aioswitcher.device import DeviceState +from aioswitcher.api import Command +from aioswitcher.api.messages import SwitcherBaseResponse +from aioswitcher.device import DeviceState, ShutterChildLock import pytest from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -86,6 +87,45 @@ async def test_switch( assert state.state == STATE_OFF +@pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) +async def test_switch_ignore_previous_async_state( + hass: HomeAssistant, mock_bridge, mock_api +) -> None: + """Test switch ignores previous async state.""" + await init_integration(hass) + assert mock_bridge + + device = DUMMY_WATER_HEATER_DEVICE + entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" + + # Test initial state - on + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test turning off + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_device" + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + # Push old state and makge sure it is ignored + mock_bridge.mock_callbacks([DUMMY_WATER_HEATER_DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(Command.OFF) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Verify new state is not ignored + mock_bridge.mock_callbacks([DUMMY_WATER_HEATER_DEVICE]) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + @pytest.mark.parametrize("mock_bridge", [[DUMMY_PLUG_DEVICE]], indirect=True) async def test_switch_control_fail( hass: HomeAssistant, @@ -240,6 +280,44 @@ async def test_child_lock_switch( assert state.state == STATE_OFF +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_child_lock_switch_ignore_previous_async_state( + hass: HomeAssistant, mock_bridge, mock_api +) -> None: + """Test child lock switch ignores previous async state.""" + await init_integration(hass) + assert mock_bridge + + entity_id = f"{SWITCH_DOMAIN}.{slugify(DEVICE.name)}_child_lock" + + # Test initial state - on + 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 + ) + + # Push old state and makge sure it is ignored + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ShutterChildLock.OFF, 0) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Verify new state is not ignored + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + @pytest.mark.parametrize( ( "device", diff --git a/tests/components/syncthru/__init__.py b/tests/components/syncthru/__init__.py index d113c11fc19..c9105c6f2b5 100644 --- a/tests/components/syncthru/__init__.py +++ b/tests/components/syncthru/__init__.py @@ -1 +1,13 @@ """Tests for the syncthru 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/syncthru/conftest.py b/tests/components/syncthru/conftest.py new file mode 100644 index 00000000000..61b91d815a2 --- /dev/null +++ b/tests/components/syncthru/conftest.py @@ -0,0 +1,80 @@ +"""Conftest for the SyncThru integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from pysyncthru import SyncthruState +import pytest + +from homeassistant.components.syncthru.const import DOMAIN +from homeassistant.const import CONF_NAME, CONF_URL + +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.syncthru.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_syncthru() -> Generator[AsyncMock]: + """Mock the SyncThru class.""" + with ( + patch( + "homeassistant.components.syncthru.coordinator.SyncThru", + autospec=True, + ) as mock_syncthru, + patch( + "homeassistant.components.syncthru.config_flow.SyncThru", new=mock_syncthru + ), + ): + client = mock_syncthru.return_value + client.model.return_value = "C430W" + client.is_unknown_state.return_value = False + client.url = "http://192.168.1.2" + client.model.return_value = "C430W" + client.hostname.return_value = "SEC84251907C415" + client.serial_number.return_value = "08HRB8GJ3F019DD" + client.device_status.return_value = SyncthruState(3) + client.device_status_details.return_value = "" + client.is_online.return_value = True + client.toner_status.return_value = { + "black": {"opt": 1, "remaining": 8, "cnt": 1176, "newError": "C1-5110"}, + "cyan": {"opt": 1, "remaining": 98, "cnt": 25, "newError": ""}, + "magenta": {"opt": 1, "remaining": 98, "cnt": 25, "newError": ""}, + "yellow": {"opt": 1, "remaining": 97, "cnt": 27, "newError": ""}, + } + client.drum_status.return_value = {} + client.input_tray_status.return_value = { + "tray_1": { + "opt": 1, + "paper_size1": 4, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 150, + "newError": "", + } + } + client.output_tray_status.return_value = { + 1: {"name": 1, "capacity": 50, "status": ""} + } + client.raw.return_value = load_json_object_fixture("state.json", DOMAIN) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="C430W", + data={CONF_URL: "http://192.168.1.2/", CONF_NAME: "My Printer"}, + ) diff --git a/tests/components/syncthru/fixtures/state.json b/tests/components/syncthru/fixtures/state.json new file mode 100644 index 00000000000..2e4a6202700 --- /dev/null +++ b/tests/components/syncthru/fixtures/state.json @@ -0,0 +1,182 @@ +{ + "status": { + "hrDeviceStatus": 3, + "status1": "", + "status2": "", + "status3": "", + "status4": "" + }, + "identity": { + "model_name": "C430W", + "device_name": "Samsung C430W", + "host_name": "SEC84251907C415", + "location": "Living room", + "serial_num": "08HRB8GJ3F019DD", + "ip_addr": "192.168.0.251", + "ipv6_link_addr": "", + "mac_addr": "84:25:19:07:C4:15", + "admin_email": "", + "admin_name": "", + "admin_phone": "", + "customer_support": "" + }, + "toner_black": { + "opt": 1, + "remaining": 8, + "cnt": 1176, + "newError": "C1-5110" + }, + "toner_cyan": { + "opt": 1, + "remaining": 98, + "cnt": 25, + "newError": "" + }, + "toner_magenta": { + "opt": 1, + "remaining": 98, + "cnt": 25, + "newError": "" + }, + "toner_yellow": { + "opt": 1, + "remaining": 97, + "cnt": 27, + "newError": "" + }, + "drum_black": { + "opt": 0, + "remaining": 44, + "newError": "" + }, + "drum_cyan": { + "opt": 0, + "remaining": 100, + "newError": "" + }, + "drum_magenta": { + "opt": 0, + "remaining": 100, + "newError": "" + }, + "drum_yellow": { + "opt": 0, + "remaining": 100, + "newError": "" + }, + "drum_color": { + "opt": 1, + "remaining": 44, + "newError": "" + }, + "tray1": { + "opt": 1, + "paper_size1": 4, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 150, + "newError": "" + }, + "tray2": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "" + }, + "tray3": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "" + }, + "tray4": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "" + }, + "tray5": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "0" + }, + "mp": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "" + }, + "manual": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "capa": 0, + "newError": "" + }, + "GXI_INTRAY_MANUALFEEDING_TRAY_SUPPORT": 0, + "GXI_INSTALL_OPTION_MULTIBIN": 0, + "multibin": [0], + "outputTray": [[1, 50, ""]], + "capability": { + "hdd": { + "opt": 2, + "capa": 40 + }, + "ram": { + "opt": 65536, + "capa": 65536 + }, + "scanner": { + "opt": 0, + "capa": 0 + } + }, + "options": { + "hdd": 0, + "wlan": 1 + }, + "GXI_ACTIVE_ALERT_TOTAL": 2, + "GXI_ADMIN_WUI_HAS_DEFAULT_PASS": 0, + "GXI_SUPPORT_COLOR": 1, + "GXI_SYS_LUI_SUPPORT": 0, + "GXI_A3_SUPPORT": 0, + "GXI_TRAY2_MANDATORY_SUPPORT": 0, + "GXI_SWS_ADMIN_USE_AAA": 0, + "GXI_TONER_BLACK_VALID": 1, + "GXI_TONER_CYAN_VALID": 1, + "GXI_TONER_MAGENTA_VALID": 1, + "GXI_TONER_YELLOW_VALID": 1, + "GXI_IMAGING_BLACK_VALID": 1, + "GXI_IMAGING_CYAN_VALID": 1, + "GXI_IMAGING_MAGENTA_VALID": 1, + "GXI_IMAGING_YELLOW_VALID": 1, + "GXI_IMAGING_COLOR_VALID": 1, + "GXI_SUPPORT_PAPER_SETTING": 1, + "GXI_SUPPORT_PAPER_LEVEL": 0, + "GXI_SUPPORT_MULTI_PASS": 1 +} diff --git a/tests/components/syncthru/snapshots/test_binary_sensor.ambr b/tests/components/syncthru/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..4f8809fd984 --- /dev/null +++ b/tests/components/syncthru/snapshots/test_binary_sensor.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.sec84251907c415_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.sec84251907c415_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_online', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.sec84251907c415_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'SEC84251907C415 Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.sec84251907c415_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.sec84251907c415_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.sec84251907c415_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_problem', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.sec84251907c415_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'SEC84251907C415 Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.sec84251907c415_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/syncthru/snapshots/test_diagnostics.ambr b/tests/components/syncthru/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..9b22561a2d6 --- /dev/null +++ b/tests/components/syncthru/snapshots/test_diagnostics.ambr @@ -0,0 +1,193 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'GXI_A3_SUPPORT': 0, + 'GXI_ACTIVE_ALERT_TOTAL': 2, + 'GXI_ADMIN_WUI_HAS_DEFAULT_PASS': 0, + 'GXI_IMAGING_BLACK_VALID': 1, + 'GXI_IMAGING_COLOR_VALID': 1, + 'GXI_IMAGING_CYAN_VALID': 1, + 'GXI_IMAGING_MAGENTA_VALID': 1, + 'GXI_IMAGING_YELLOW_VALID': 1, + 'GXI_INSTALL_OPTION_MULTIBIN': 0, + 'GXI_INTRAY_MANUALFEEDING_TRAY_SUPPORT': 0, + 'GXI_SUPPORT_COLOR': 1, + 'GXI_SUPPORT_MULTI_PASS': 1, + 'GXI_SUPPORT_PAPER_LEVEL': 0, + 'GXI_SUPPORT_PAPER_SETTING': 1, + 'GXI_SWS_ADMIN_USE_AAA': 0, + 'GXI_SYS_LUI_SUPPORT': 0, + 'GXI_TONER_BLACK_VALID': 1, + 'GXI_TONER_CYAN_VALID': 1, + 'GXI_TONER_MAGENTA_VALID': 1, + 'GXI_TONER_YELLOW_VALID': 1, + 'GXI_TRAY2_MANDATORY_SUPPORT': 0, + 'capability': dict({ + 'hdd': dict({ + 'capa': 40, + 'opt': 2, + }), + 'ram': dict({ + 'capa': 65536, + 'opt': 65536, + }), + 'scanner': dict({ + 'capa': 0, + 'opt': 0, + }), + }), + 'drum_black': dict({ + 'newError': '', + 'opt': 0, + 'remaining': 44, + }), + 'drum_color': dict({ + 'newError': '', + 'opt': 1, + 'remaining': 44, + }), + 'drum_cyan': dict({ + 'newError': '', + 'opt': 0, + 'remaining': 100, + }), + 'drum_magenta': dict({ + 'newError': '', + 'opt': 0, + 'remaining': 100, + }), + 'drum_yellow': dict({ + 'newError': '', + 'opt': 0, + 'remaining': 100, + }), + 'identity': dict({ + 'admin_email': '', + 'admin_name': '', + 'admin_phone': '', + 'customer_support': '', + 'device_name': 'Samsung C430W', + 'host_name': 'SEC84251907C415', + 'ip_addr': '192.168.0.251', + 'ipv6_link_addr': '', + 'location': 'Living room', + 'mac_addr': '84:25:19:07:C4:15', + 'model_name': 'C430W', + 'serial_num': '08HRB8GJ3F019DD', + }), + 'manual': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'mp': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'multibin': list([ + 0, + ]), + 'options': dict({ + 'hdd': 0, + 'wlan': 1, + }), + 'outputTray': list([ + list([ + 1, + 50, + '', + ]), + ]), + 'status': dict({ + 'hrDeviceStatus': 3, + 'status1': '', + 'status2': '', + 'status3': '', + 'status4': '', + }), + 'toner_black': dict({ + 'cnt': 1176, + 'newError': 'C1-5110', + 'opt': 1, + 'remaining': 8, + }), + 'toner_cyan': dict({ + 'cnt': 25, + 'newError': '', + 'opt': 1, + 'remaining': 98, + }), + 'toner_magenta': dict({ + 'cnt': 25, + 'newError': '', + 'opt': 1, + 'remaining': 98, + }), + 'toner_yellow': dict({ + 'cnt': 27, + 'newError': '', + 'opt': 1, + 'remaining': 97, + }), + 'tray1': dict({ + 'capa': 150, + 'newError': '', + 'opt': 1, + 'paper_level': 0, + 'paper_size1': 4, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'tray2': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'tray3': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'tray4': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'tray5': dict({ + 'capa': 0, + 'newError': '0', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + }) +# --- diff --git a/tests/components/syncthru/snapshots/test_sensor.ambr b/tests/components/syncthru/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..b7edc046879 --- /dev/null +++ b/tests/components/syncthru/snapshots/test_sensor.ambr @@ -0,0 +1,418 @@ +# serializer version: 1 +# name: test_all_entities[sensor.sec84251907c415-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sec84251907c415', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': None, + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sec84251907c415-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'display_text': '', + 'friendly_name': 'SEC84251907C415', + 'icon': 'mdi:printer', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'warning', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_active_alerts-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sec84251907c415_active_alerts', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Active alerts', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_alerts', + 'unique_id': '08HRB8GJ3F019DD_active_alerts', + 'unit_of_measurement': 'alerts', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_active_alerts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SEC84251907C415 Active alerts', + 'icon': 'mdi:printer', + 'unit_of_measurement': 'alerts', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_active_alerts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_black_toner_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sec84251907c415_black_toner_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Black toner level', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'toner_black', + 'unique_id': '08HRB8GJ3F019DD_toner_black', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_black_toner_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 1176, + 'friendly_name': 'SEC84251907C415 Black toner level', + 'icon': 'mdi:printer', + 'newError': 'C1-5110', + 'opt': 1, + 'remaining': 8, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_black_toner_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_cyan_toner_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sec84251907c415_cyan_toner_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Cyan toner level', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'toner_cyan', + 'unique_id': '08HRB8GJ3F019DD_toner_cyan', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_cyan_toner_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 25, + 'friendly_name': 'SEC84251907C415 Cyan toner level', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'remaining': 98, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_cyan_toner_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_input_tray_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sec84251907c415_input_tray_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Input tray 1', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tray', + 'unique_id': '08HRB8GJ3F019DD_tray_tray_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_input_tray_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'capa': 150, + 'friendly_name': 'SEC84251907C415 Input tray 1', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'paper_level': 0, + 'paper_size1': 4, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_input_tray_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Ready', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_magenta_toner_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sec84251907c415_magenta_toner_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Magenta toner level', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'toner_magenta', + 'unique_id': '08HRB8GJ3F019DD_toner_magenta', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_magenta_toner_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 25, + 'friendly_name': 'SEC84251907C415 Magenta toner level', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'remaining': 98, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_magenta_toner_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_output_tray_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sec84251907c415_output_tray_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Output tray 1', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_tray', + 'unique_id': '08HRB8GJ3F019DD_output_tray_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_output_tray_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'capacity': 50, + 'friendly_name': 'SEC84251907C415 Output tray 1', + 'icon': 'mdi:printer', + 'name': 1, + 'status': '', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_output_tray_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Ready', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_yellow_toner_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sec84251907c415_yellow_toner_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Yellow toner level', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'toner_yellow', + 'unique_id': '08HRB8GJ3F019DD_toner_yellow', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_yellow_toner_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 27, + 'friendly_name': 'SEC84251907C415 Yellow toner level', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'remaining': 97, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_yellow_toner_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97', + }) +# --- diff --git a/tests/components/syncthru/test_binary_sensor.py b/tests/components/syncthru/test_binary_sensor.py new file mode 100644 index 00000000000..ae5f0b6a90c --- /dev/null +++ b/tests/components/syncthru/test_binary_sensor.py @@ -0,0 +1,27 @@ +"""Tests for the Syncthru binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_syncthru: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.syncthru.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py index 727b95563cc..e535ba50470 100644 --- a/tests/components/syncthru/test_config_flow.py +++ b/tests/components/syncthru/test_config_flow.py @@ -1,13 +1,12 @@ """Tests for syncthru config flow.""" -import re -from unittest.mock import patch +from unittest.mock import AsyncMock from pysyncthru import SyncThruAPINotSupported from homeassistant import config_entries -from homeassistant.components.syncthru.config_flow import SyncThru from homeassistant.components.syncthru.const import DOMAIN +from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -21,7 +20,6 @@ from homeassistant.helpers.service_info.ssdp import ( ) from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker FIXTURE_USER_INPUT = { CONF_URL: "http://192.168.1.2/", @@ -29,36 +27,29 @@ FIXTURE_USER_INPUT = { } -def mock_connection(aioclient_mock): - """Mock syncthru connection.""" - aioclient_mock.get( - re.compile("."), - text=""" -{ -\tstatus: { -\thrDeviceStatus: 2, -\tstatus1: " Sleeping... " -\t}, -\tidentity: { -\tserial_num: "000000000000000", -\t} -} - """, - ) - - -async def test_show_setup_form(hass: HomeAssistant) -> None: - """Test that the setup form is served.""" +async def test_full_flow( + hass: HomeAssistant, mock_syncthru: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test the full flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None + 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=FIXTURE_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == FIXTURE_USER_INPUT + assert result["result"].unique_id is None + async def test_already_configured_by_url( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, mock_syncthru: AsyncMock ) -> None: """Test we match and update already configured devices by URL.""" @@ -69,7 +60,6 @@ async def test_already_configured_by_url( title="Already configured", unique_id=udn, ).add_to_hass(hass) - mock_connection(aioclient_mock) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -83,68 +73,61 @@ async def test_already_configured_by_url( assert result["result"].unique_id == udn -async def test_syncthru_not_supported(hass: HomeAssistant) -> None: +async def test_syncthru_not_supported( + hass: HomeAssistant, mock_syncthru: AsyncMock +) -> None: """Test we show user form on unsupported device.""" - with patch.object(SyncThru, "update", side_effect=SyncThruAPINotSupported): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, - ) + mock_syncthru.update.side_effect = SyncThruAPINotSupported + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=FIXTURE_USER_INPUT, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_URL: "syncthru_not_supported"} -async def test_unknown_state(hass: HomeAssistant) -> None: +async def test_unknown_state(hass: HomeAssistant, mock_syncthru: AsyncMock) -> None: """Test we show user form on unsupported device.""" - with ( - patch.object(SyncThru, "update"), - patch.object(SyncThru, "is_unknown_state", return_value=True), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, - ) + mock_syncthru.is_unknown_state.return_value = True + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=FIXTURE_USER_INPUT, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_URL: "unknown_state"} + mock_syncthru.is_unknown_state.return_value = False -async def test_success( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test successful flow provides entry creation data.""" - - mock_connection(aioclient_mock) - - with patch( - "homeassistant.components.syncthru.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=FIXTURE_USER_INPUT, - ) - await hass.async_block_till_done() - + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=FIXTURE_USER_INPUT, + ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_URL] == FIXTURE_USER_INPUT[CONF_URL] - assert len(mock_setup_entry.mock_calls) == 1 -async def test_ssdp(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_ssdp( + hass: HomeAssistant, mock_syncthru: AsyncMock, mock_setup_entry: AsyncMock +) -> None: """Test SSDP discovery initiates config properly.""" - mock_connection(aioclient_mock) - url = "http://192.168.1.2/" result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, + context={"source": SOURCE_SSDP}, data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", @@ -165,3 +148,44 @@ async def test_ssdp(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> for k in result["data_schema"].schema: if k == CONF_URL: assert k.default() == url + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: url, CONF_NAME: "Printer"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_URL: url, CONF_NAME: "Printer"} + assert result["result"].unique_id == "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" + + +async def test_ssdp_already_configured( + hass: HomeAssistant, mock_syncthru: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test SSDP discovery initiates config properly.""" + + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, unique_id="uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" + ) + + url = "http://192.168.1.2/" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.2:5200/Printer.xml", + upnp={ + 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", + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/syncthru/test_diagnostics.py b/tests/components/syncthru/test_diagnostics.py new file mode 100644 index 00000000000..f5988936328 --- /dev/null +++ b/tests/components/syncthru/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for the diagnostics data provided by the Syncthru integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_syncthru: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/syncthru/test_sensor.py b/tests/components/syncthru/test_sensor.py new file mode 100644 index 00000000000..600e2962730 --- /dev/null +++ b/tests/components/syncthru/test_sensor.py @@ -0,0 +1,29 @@ +"""Tests for the Syncthru sensor platform.""" + +from unittest.mock import AsyncMock, patch + +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 . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_syncthru: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.syncthru.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/synology_dsm/common.py b/tests/components/synology_dsm/common.py index e98b0d21d66..3b069d04ebe 100644 --- a/tests/components/synology_dsm/common.py +++ b/tests/components/synology_dsm/common.py @@ -12,11 +12,21 @@ from .consts import SERIAL def mock_dsm_information( serial: str | None = SERIAL, update_result: bool = True, - awesome_version: str = "7.2", + awesome_version: str = "7.2.2", + model: str = "DS1821+", + version_string: str = "DSM 7.2.2-72806 Update 3", + ram: int = 32768, + temperature: int = 58, + uptime: int = 123456, ) -> Mock: """Mock SynologyDSM information.""" return Mock( serial=serial, update=AsyncMock(return_value=update_result), awesome_version=AwesomeVersion(awesome_version), + model=model, + version_string=version_string, + ram=ram, + temperature=temperature, + uptime=uptime, ) diff --git a/tests/components/synology_dsm/snapshots/test_diagnostics.ambr b/tests/components/synology_dsm/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..cd8b1be42b2 --- /dev/null +++ b/tests/components/synology_dsm/snapshots/test_diagnostics.ambr @@ -0,0 +1,130 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'device_info': dict({ + 'model': 'DS1821+', + 'ram': 32768, + 'temperature': 58, + 'uptime': 123456, + 'version': 'DSM 7.2.2-72806 Update 3', + }), + 'entry': dict({ + 'data': dict({ + 'host': 'nas.meontheinternet.com', + 'mac': '00-11-32-XX-XX-59', + 'password': '**REDACTED**', + 'port': 1234, + 'ssl': True, + 'username': '**REDACTED**', + 'verify_ssl': False, + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'synology_dsm', + 'minor_version': 1, + 'options': dict({ + 'backup_path': None, + 'backup_share': None, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': 'mySerial', + 'version': 1, + }), + 'external_usb': dict({ + 'devices': dict({ + 'usb1': dict({ + 'manufacturer': 'Western Digital Technologies, Inc.', + 'model': 'easystore 264D', + 'name': 'USB Disk 1', + 'size_total': 16000900661248, + 'status': 'normal', + 'type': 'usbDisk', + }), + }), + 'partitions': dict({ + 'usb1p1': dict({ + 'filesystem': 'ntfs', + 'name': 'USB Disk 1 Partition 1', + 'share_name': 'usbshare1', + 'size_total': 16000898564096, + 'size_used': 6231101014016, + }), + }), + }), + 'is_system_loaded': True, + 'network': dict({ + 'interfaces': dict({ + 'ovs_eth0': dict({ + 'ip': list([ + dict({ + 'address': '127.0.0.1', + 'netmask': '255.255.255.0', + }), + ]), + 'type': 'ovseth', + }), + }), + }), + 'storage': dict({ + 'disks': dict({ + }), + 'volumes': dict({ + }), + }), + 'surveillance_station': dict({ + 'camera_diagnostics': dict({ + }), + 'cameras': dict({ + }), + }), + 'upgrade': dict({ + 'available_version': None, + 'reboot_needed': None, + 'service_restarts': None, + 'update_available': False, + }), + 'utilisation': dict({ + 'cpu': dict({ + '15min_load': 461, + '1min_load': 410, + '5min_load': 404, + 'device': 'System', + 'other_load': 5, + 'system_load': 11, + 'user_load': 11, + }), + 'memory': dict({ + 'avail_real': 463628, + 'avail_swap': 0, + 'buffer': 10556600, + 'cached': 5297776, + 'device': 'Memory', + 'memory_size': 33554432, + 'real_usage': 50, + 'si_disk': 0, + 'so_disk': 0, + 'swap_usage': 100, + 'total_real': 32841680, + 'total_swap': 2097084, + }), + 'network': list([ + dict({ + 'device': 'total', + 'rx': 1065612, + 'tx': 36311, + }), + dict({ + 'device': 'eth0', + 'rx': 1065612, + 'tx': 36311, + }), + ]), + }), + }) +# --- diff --git a/tests/components/synology_dsm/test_diagnostics.py b/tests/components/synology_dsm/test_diagnostics.py new file mode 100644 index 00000000000..f2bb35f488d --- /dev/null +++ b/tests/components/synology_dsm/test_diagnostics.py @@ -0,0 +1,199 @@ +"""Test Synology DSM diagnostics.""" + +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from synology_dsm.api.core.external_usb import SynoCoreExternalUSBDevice +from synology_dsm.api.dsm.network import NetworkInterface +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.synology_dsm.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant + +from .common import mock_dsm_information +from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.fixture +def mock_dsm_with_usb(): + """Mock a successful service with USB support.""" + with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade = Mock( + update_available=False, + available_version=None, + reboot_needed=None, + service_restarts=None, + update=AsyncMock(return_value=True), + ) + dsm.utilisation = Mock( + cpu={ + "15min_load": 461, + "1min_load": 410, + "5min_load": 404, + "device": "System", + "other_load": 5, + "system_load": 11, + "user_load": 11, + }, + memory={ + "avail_real": 463628, + "avail_swap": 0, + "buffer": 10556600, + "cached": 5297776, + "device": "Memory", + "memory_size": 33554432, + "real_usage": 50, + "si_disk": 0, + "so_disk": 0, + "swap_usage": 100, + "total_real": 32841680, + "total_swap": 2097084, + }, + network=[ + {"device": "total", "rx": 1065612, "tx": 36311}, + {"device": "eth0", "rx": 1065612, "tx": 36311}, + ], + memory_available_swap=Mock(return_value=0), + memory_available_real=Mock(return_value=463628), + memory_total_swap=Mock(return_value=2097084), + memory_total_real=Mock(return_value=32841680), + network_up=Mock(return_value=1065612), + network_down=Mock(return_value=36311), + update=AsyncMock(return_value=True), + ) + dsm.network = Mock( + update=AsyncMock(return_value=True), + macs=MACS, + hostname=HOST, + interfaces=[ + NetworkInterface( + { + "id": "ovs_eth0", + "ip": [{"address": "127.0.0.1", "netmask": "255.255.255.0"}], + "type": "ovseth", + } + ) + ], + ) + dsm.information = mock_dsm_information() + dsm.file = Mock(get_shared_folders=AsyncMock(return_value=None)) + dsm.external_usb = Mock( + update=AsyncMock(return_value=True), + get_device=Mock( + return_value=SynoCoreExternalUSBDevice( + { + "dev_id": "usb1", + "dev_type": "usbDisk", + "dev_title": "USB Disk 1", + "producer": "Western Digital Technologies, Inc.", + "product": "easystore 264D", + "formatable": True, + "progress": "", + "status": "normal", + "total_size_mb": 15259648, + "partitions": [ + { + "dev_fstype": "ntfs", + "filesystem": "ntfs", + "name_id": "usb1p1", + "partition_title": "USB Disk 1 Partition 1", + "share_name": "usbshare1", + "status": "normal", + "total_size_mb": 15259646, + "used_size_mb": 5942441, + } + ], + } + ) + ), + get_devices={ + "usb1": SynoCoreExternalUSBDevice( + { + "dev_id": "usb1", + "dev_type": "usbDisk", + "dev_title": "USB Disk 1", + "producer": "Western Digital Technologies, Inc.", + "product": "easystore 264D", + "formatable": True, + "progress": "", + "status": "normal", + "total_size_mb": 15259648, + "partitions": [ + { + "dev_fstype": "ntfs", + "filesystem": "ntfs", + "name_id": "usb1p1", + "partition_title": "USB Disk 1 Partition 1", + "share_name": "usbshare1", + "status": "normal", + "total_size_mb": 15259646, + "used_size_mb": 5942441, + } + ], + } + ) + }, + ) + dsm.logout = AsyncMock(return_value=True) + yield dsm + + +@pytest.fixture +async def setup_dsm_with_usb( + hass: HomeAssistant, + mock_dsm_with_usb: MagicMock, +): + """Mock setup of synology dsm config entry with USB.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=mock_dsm_with_usb, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id=SERIAL, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + yield mock_dsm_with_usb + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_dsm_with_usb: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for Synology DSM config entry.""" + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == snapshot( + exclude=props("api_details", "created_at", "modified_at", "entry_id") + ) diff --git a/tests/components/synology_dsm/test_sensor.py b/tests/components/synology_dsm/test_sensor.py new file mode 100644 index 00000000000..654cade2462 --- /dev/null +++ b/tests/components/synology_dsm/test_sensor.py @@ -0,0 +1,242 @@ +"""Tests for Synology DSM USB.""" + +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from synology_dsm.api.core.external_usb import SynoCoreExternalUSBDevice + +from homeassistant.components.synology_dsm.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import mock_dsm_information +from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_dsm_with_usb(): + """Mock a successful service with USB support.""" + with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade.update = AsyncMock(return_value=True) + dsm.network = Mock( + update=AsyncMock(return_value=True), macs=MACS, hostname=HOST + ) + dsm.information = mock_dsm_information() + dsm.file = Mock(get_shared_folders=AsyncMock(return_value=None)) + dsm.external_usb = Mock( + update=AsyncMock(return_value=True), + get_device=Mock( + return_value=SynoCoreExternalUSBDevice( + { + "dev_id": "usb1", + "dev_type": "usbDisk", + "dev_title": "USB Disk 1", + "producer": "Western Digital Technologies, Inc.", + "product": "easystore 264D", + "formatable": True, + "progress": "", + "status": "normal", + "total_size_mb": 15259648, + "partitions": [ + { + "dev_fstype": "ntfs", + "filesystem": "ntfs", + "name_id": "usb1p1", + "partition_title": "USB Disk 1 Partition 1", + "share_name": "usbshare1", + "status": "normal", + "total_size_mb": 15259646, + "used_size_mb": 5942441, + } + ], + } + ) + ), + get_devices={ + "usb1": SynoCoreExternalUSBDevice( + { + "dev_id": "usb1", + "dev_type": "usbDisk", + "dev_title": "USB Disk 1", + "producer": "Western Digital Technologies, Inc.", + "product": "easystore 264D", + "formatable": True, + "progress": "", + "status": "normal", + "total_size_mb": 15259648, + "partitions": [ + { + "dev_fstype": "ntfs", + "filesystem": "ntfs", + "name_id": "usb1p1", + "partition_title": "USB Disk 1 Partition 1", + "share_name": "usbshare1", + "status": "normal", + "total_size_mb": 15259646, + "used_size_mb": 5942441, + } + ], + } + ) + }, + ) + dsm.logout = AsyncMock(return_value=True) + yield dsm + + +@pytest.fixture +def mock_dsm_without_usb(): + """Mock a successful service without USB devices.""" + with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade.update = AsyncMock(return_value=True) + dsm.network = Mock( + update=AsyncMock(return_value=True), macs=MACS, hostname=HOST + ) + dsm.information = mock_dsm_information() + dsm.file = Mock(get_shared_folders=AsyncMock(return_value=None)) + dsm.logout = AsyncMock(return_value=True) + yield dsm + + +@pytest.fixture +async def setup_dsm_with_usb( + hass: HomeAssistant, + mock_dsm_with_usb: MagicMock, +): + """Mock setup of synology dsm config entry with USB.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=mock_dsm_with_usb, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id=SERIAL, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + yield mock_dsm_with_usb + + +@pytest.fixture +async def setup_dsm_without_usb( + hass: HomeAssistant, + mock_dsm_without_usb: MagicMock, +): + """Mock setup of synology dsm config entry without USB.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=mock_dsm_without_usb, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id=SERIAL, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + yield mock_dsm_without_usb + + +async def test_external_usb( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_dsm_with_usb: MagicMock, +) -> None: + """Test Synology DSM USB sensors.""" + # test disabled device size sensor + entity_id = "sensor.nas_meontheinternet_com_usb_disk_1_device_size" + entity_entry = entity_registry.async_get(entity_id) + + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + # test partition size sensor + sensor = hass.states.get( + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_size" + ) + assert sensor is not None + assert sensor.state == "14901.998046875" + assert ( + sensor.attributes["friendly_name"] + == "nas.meontheinternet.com (USB Disk 1 Partition 1) Partition size" + ) + assert sensor.attributes["device_class"] == "data_size" + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "GiB" + assert sensor.attributes["attribution"] == "Data provided by Synology" + + # test partition used space sensor + sensor = hass.states.get( + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used_space" + ) + assert sensor is not None + assert sensor.state == "5803.1650390625" + assert ( + sensor.attributes["friendly_name"] + == "nas.meontheinternet.com (USB Disk 1 Partition 1) Partition used space" + ) + assert sensor.attributes["device_class"] == "data_size" + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "GiB" + assert sensor.attributes["attribution"] == "Data provided by Synology" + + # test partition used sensor + sensor = hass.states.get( + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used" + ) + assert sensor is not None + assert sensor.state == "38.9" + assert ( + sensor.attributes["friendly_name"] + == "nas.meontheinternet.com (USB Disk 1 Partition 1) Partition used" + ) + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "%" + assert sensor.attributes["attribution"] == "Data provided by Synology" + + +async def test_no_external_usb( + hass: HomeAssistant, + setup_dsm_without_usb: MagicMock, +) -> None: + """Test Synology DSM without USB.""" + sensor = hass.states.get("sensor.nas_meontheinternet_com_usb_disk_1_device_size") + assert sensor is None diff --git a/tests/components/system_bridge/snapshots/test_media_source.ambr b/tests/components/system_bridge/snapshots/test_media_source.ambr index 53e0e8416e9..954332c932a 100644 --- a/tests/components/system_bridge/snapshots/test_media_source.ambr +++ b/tests/components/system_bridge/snapshots/test_media_source.ambr @@ -3,6 +3,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_type': '', @@ -15,6 +16,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_type': '', @@ -39,6 +41,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_type': '', @@ -51,6 +54,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_type': '', diff --git a/tests/components/tado/snapshots/test_diagnostics.ambr b/tests/components/tado/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..eefb818a88c --- /dev/null +++ b/tests/components/tado/snapshots/test_diagnostics.ambr @@ -0,0 +1,143 @@ +# serializer version: 1 +# name: test_get_config_entry_diagnostics + dict({ + 'data': dict({ + 'device': dict({ + 'WR1': dict({ + 'accessPointWiFi': dict({ + 'ssid': 'tado8480', + }), + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'commandTableUploadState': 'FINISHED', + 'connectionState': dict({ + 'timestamp': '2020-03-23T18:30:07.377Z', + 'value': True, + }), + 'currentFwVersion': '59.4', + 'deviceType': 'WR02', + 'serialNo': 'WR1', + 'shortSerialNo': 'WR1', + 'temperatureOffset': dict({ + 'celsius': -1.0, + 'fahrenheit': -1.8, + }), + }), + 'WR4': dict({ + 'accessPointWiFi': dict({ + 'ssid': 'tado8480', + }), + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'commandTableUploadState': 'FINISHED', + 'connectionState': dict({ + 'timestamp': '2020-03-23T18:30:07.377Z', + 'value': True, + }), + 'currentFwVersion': '59.4', + 'deviceType': 'WR02', + 'duties': list([ + 'ZONE_UI', + 'ZONE_DRIVER', + 'ZONE_LEADER', + ]), + 'serialNo': 'WR4', + 'shortSerialNo': 'WR4', + 'temperatureOffset': dict({ + 'celsius': -1.0, + 'fahrenheit': -1.8, + }), + }), + }), + 'geofence': dict({ + 'presence': 'HOME', + 'presenceLocked': False, + }), + 'weather': dict({ + 'outsideTemperature': dict({ + 'celsius': 7.46, + 'fahrenheit': 45.43, + 'precision': dict({ + 'celsius': 0.01, + 'fahrenheit': 0.01, + }), + 'timestamp': '2020-12-22T08:13:13.652Z', + 'type': 'TEMPERATURE', + }), + 'solarIntensity': dict({ + 'percentage': 2.1, + 'timestamp': '2020-12-22T08:13:13.652Z', + 'type': 'PERCENTAGE', + }), + 'weatherState': dict({ + 'timestamp': '2020-12-22T08:13:13.652Z', + 'type': 'WEATHER_STATE', + 'value': 'FOGGY', + }), + }), + 'zone': dict({ + '1': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=1, current_temp=20.65, connection=None, current_temp_timestamp='2020-03-10T07:44:11.947Z', current_humidity=45.2, current_humidity_timestamp='2020-03-10T07:44:11.947Z', is_away=False, current_hvac_action='IDLE', current_fan_speed=None, current_fan_level=None, current_hvac_mode='HEAT', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=20.5, available=True, power='ON', link='ONLINE', ac_power_timestamp=None, heating_power_timestamp='2020-03-10T07:47:45.978Z', ac_power=None, heating_power=None, heating_power_percentage=0.0, tado_mode='HOME', overlay_termination_type='MANUAL', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '2': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=2, current_temp=None, connection=None, current_temp_timestamp=None, current_humidity=None, current_humidity_timestamp=None, is_away=False, current_hvac_action='IDLE', current_fan_speed=None, current_fan_level=None, current_hvac_mode='SMART_SCHEDULE', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=65.0, available=True, power='ON', link='ONLINE', ac_power_timestamp=None, heating_power_timestamp=None, ac_power=None, heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type=None, overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '3': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=3, current_temp=24.76, connection=None, current_temp_timestamp='2020-03-05T03:57:38.850Z', current_humidity=60.9, current_humidity_timestamp='2020-03-05T03:57:38.850Z', is_away=False, current_hvac_action='COOLING', current_fan_speed='AUTO', current_fan_level=None, current_hvac_mode='COOL', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=17.78, available=True, power='ON', link='ONLINE', ac_power_timestamp='2020-03-05T04:01:07.162Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='TADO_MODE', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '4': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=4, current_temp=None, connection=None, current_temp_timestamp=None, current_humidity=None, current_humidity_timestamp=None, is_away=False, current_hvac_action='IDLE', current_fan_speed=None, current_fan_level=None, current_hvac_mode='HEATING', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=30.0, available=True, power='ON', link='ONLINE', ac_power_timestamp=None, heating_power_timestamp=None, ac_power=None, heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='TADO_MODE', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '5': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=5, current_temp=20.88, connection=None, current_temp_timestamp='2020-03-28T02:09:27.830Z', current_humidity=42.3, current_humidity_timestamp='2020-03-28T02:09:27.830Z', is_away=False, current_hvac_action='HEATING', current_fan_speed='AUTO', current_fan_level=None, current_hvac_mode='SMART_SCHEDULE', current_swing_mode='ON', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=20.0, available=True, power='ON', link='ONLINE', ac_power_timestamp='2020-03-27T23:02:22.260Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type=None, overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '6': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=6, current_temp=24.3, connection=None, current_temp_timestamp='2024-06-28T22: 23: 15.679Z', current_humidity=70.9, current_humidity_timestamp='2024-06-28T22: 23: 15.679Z', is_away=False, current_hvac_action='HEATING', current_fan_speed='AUTO', current_fan_level='LEVEL3', current_hvac_mode='HEAT', current_swing_mode='OFF', current_vertical_swing_mode='ON', current_horizontal_swing_mode='ON', target_temp=25.0, available=True, power='ON', link='ONLINE', ac_power_timestamp='2022-07-13T18: 06: 58.183Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='MANUAL', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + }), + }), + 'mobile_devices': dict({ + 'mobile_device': dict({ + '123456': dict({ + 'deviceMetadata': dict({ + 'locale': 'nl', + 'model': 'Samsung', + 'osVersion': '14', + 'platform': 'Android', + }), + 'id': 123456, + 'name': 'Home', + 'settings': dict({ + 'geoTrackingEnabled': False, + 'onDemandLogRetrievalEnabled': False, + 'pushNotifications': dict({ + 'awayModeReminder': True, + 'energyIqReminder': False, + 'energySavingsReportReminder': True, + 'homeModeReminder': True, + 'incidentDetection': True, + 'lowBatteryReminder': True, + 'openWindowReminder': True, + }), + 'specialOffersEnabled': False, + }), + }), + }), + }), + }) +# --- diff --git a/tests/components/tado/test_diagnostics.py b/tests/components/tado/test_diagnostics.py new file mode 100644 index 00000000000..3a4f04b0a4c --- /dev/null +++ b/tests/components/tado/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Test the Tado component diagnostics.""" + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.tado.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + +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, +) -> None: + """Test if get_config_entry_diagnostics returns the correct data.""" + await async_init_integration(hass) + + config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + assert diagnostics == snapshot(exclude=props("created_at", "modified_at")) diff --git a/tests/components/tado/test_init.py b/tests/components/tado/test_init.py index 2f2ccacf3c0..10acd8eef59 100644 --- a/tests/components/tado/test_init.py +++ b/tests/components/tado/test_init.py @@ -1,7 +1,13 @@ """Test the Tado integration.""" +import asyncio +import threading +import time +from unittest.mock import patch + +from PyTado.http import Http + from homeassistant.components.tado import DOMAIN -from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -24,7 +30,37 @@ async def test_v1_migration(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entry.version == 2 assert CONF_USERNAME not in entry.data - assert CONF_PASSWORD not in entry.data - assert entry.state is ConfigEntryState.SETUP_ERROR - assert len(hass.config_entries.flow.async_progress()) == 1 + +async def test_refresh_token_threading_lock(hass: HomeAssistant) -> None: + """Test that threading.Lock in Http._refresh_token serializes concurrent calls.""" + + timestamps: list[tuple[str, float]] = [] + lock = threading.Lock() + + def fake_refresh_token(*args, **kwargs) -> bool: + """Simulate the refresh token process with a threading lock.""" + with lock: + timestamps.append(("start", time.monotonic())) + time.sleep(0.2) + timestamps.append(("end", time.monotonic())) + return True + + with ( + patch("PyTado.http.Http._refresh_token", side_effect=fake_refresh_token), + patch("PyTado.http.Http.__init__", return_value=None), + ): + http_instance = Http() + + # Run two concurrent refresh token calls, should do the trick + await asyncio.gather( + hass.async_add_executor_job(http_instance._refresh_token), + hass.async_add_executor_job(http_instance._refresh_token), + ) + + end1 = timestamps[1][1] + start2 = timestamps[2][1] + + assert start2 >= end1, ( + f"Second refresh started before first ended: start2={start2}, end1={end1}." + ) diff --git a/tests/components/tailscale/fixtures/devices.json b/tests/components/tailscale/fixtures/devices.json index 66dc262127a..fdfd1d9259a 100644 --- a/tests/components/tailscale/fixtures/devices.json +++ b/tests/components/tailscale/fixtures/devices.json @@ -104,6 +104,28 @@ "upnp": false } } + }, + { + "addresses": ["100.11.11.113"], + "id": "123458", + "user": "frenck", + "name": "host-no-connectivity.homeassistant.github", + "hostname": "host-no-connectivity", + "clientVersion": "1.14.0-t5cff36945-g809e87bba", + "updateAvailable": true, + "os": "linux", + "created": "2021-08-29T09:49:06Z", + "lastSeen": "2021-11-15T20:37:03Z", + "keyExpiryDisabled": false, + "expires": "2022-02-25T09:49:06Z", + "authorized": true, + "isExternal": false, + "machineKey": "mkey:mock", + "nodeKey": "nodekey:mock", + "blocksIncomingConnections": false, + "enabledRoutes": ["0.0.0.0/0", "10.10.10.0/23", "::/0"], + "advertisedRoutes": ["0.0.0.0/0", "10.10.10.0/23", "::/0"], + "clientConnectivity": null } ] } diff --git a/tests/components/tailscale/snapshots/test_diagnostics.ambr b/tests/components/tailscale/snapshots/test_diagnostics.ambr index eba8d9bd145..f3f90c641ea 100644 --- a/tests/components/tailscale/snapshots/test_diagnostics.ambr +++ b/tests/components/tailscale/snapshots/test_diagnostics.ambr @@ -82,6 +82,38 @@ 'update_available': True, 'user': '**REDACTED**', }), + dict({ + 'addresses': '**REDACTED**', + 'advertised_routes': list([ + '0.0.0.0/0', + '10.10.10.0/23', + '::/0', + ]), + 'authorized': True, + 'blocks_incoming_connections': False, + 'client_connectivity': None, + 'client_version': '1.14.0-t5cff36945-g809e87bba', + 'created': '2021-08-29T09:49:06+00:00', + 'device_id': '**REDACTED**', + 'enabled_routes': list([ + '0.0.0.0/0', + '10.10.10.0/23', + '::/0', + ]), + 'expires': '2022-02-25T09:49:06+00:00', + 'hostname': '**REDACTED**', + 'is_external': False, + 'key_expiry_disabled': False, + 'last_seen': '2021-11-15T20:37:03+00:00', + 'machine_key': '**REDACTED**', + 'name': '**REDACTED**', + 'node_key': '**REDACTED**', + 'os': 'linux', + 'tags': list([ + ]), + 'update_available': True, + 'user': '**REDACTED**', + }), ]), }) # --- diff --git a/tests/components/tailscale/test_binary_sensor.py b/tests/components/tailscale/test_binary_sensor.py index b2b593101d7..e0ac97865f0 100644 --- a/tests/components/tailscale/test_binary_sensor.py +++ b/tests/components/tailscale/test_binary_sensor.py @@ -6,7 +6,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, ) from homeassistant.components.tailscale.const import DOMAIN -from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, EntityCategory +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + STATE_UNKNOWN, + EntityCategory, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -122,3 +127,19 @@ async def test_tailscale_binary_sensors( device_entry.configuration_url == "https://login.tailscale.com/admin/machines/100.11.11.111" ) + + # Check host without client connectivity attribute + state = hass.states.get("binary_sensor.host_no_connectivity_supports_hairpinning") + entry = entity_registry.async_get( + "binary_sensor.host_no_connectivity_supports_hairpinning" + ) + assert entry + assert state + assert entry.unique_id == "123458_client_supports_hair_pinning" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_UNKNOWN + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "host-no-connectivity Supports hairpinning" + ) + assert ATTR_DEVICE_CLASS not in state.attributes diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index 86a30535e92..c69c9e9e9a4 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -16,6 +16,7 @@ class ConfigurationStyle(Enum): LEGACY = "Legacy" MODERN = "Modern" + TRIGGER = "Trigger" @pytest.fixture diff --git a/tests/components/template/test_blueprint.py b/tests/components/template/test_blueprint.py index 66630ecf739..312c04b670c 100644 --- a/tests/components/template/test_blueprint.py +++ b/tests/components/template/test_blueprint.py @@ -212,11 +212,16 @@ async def test_reload_template_when_blueprint_changes(hass: HomeAssistant) -> No assert not_inverted.state == "on" +@pytest.mark.parametrize( + ("blueprint"), + ["test_event_sensor.yaml", "test_event_sensor_legacy_schema.yaml"], +) async def test_trigger_event_sensor( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + blueprint: str, ) -> None: """Test event sensor blueprint.""" - blueprint = "test_event_sensor.yaml" assert await async_setup_component( hass, "template", @@ -267,6 +272,101 @@ async def test_trigger_event_sensor( await template.async_get_blueprints(hass).async_remove_blueprint(blueprint) +@pytest.mark.parametrize( + ("blueprint", "override"), + [ + # Override a blueprint with modern schema with legacy schema + ( + "test_event_sensor.yaml", + {"trigger": {"platform": "event", "event_type": "override"}}, + ), + # Override a blueprint with modern schema with modern schema + ( + "test_event_sensor.yaml", + {"triggers": {"platform": "event", "event_type": "override"}}, + ), + # Override a blueprint with legacy schema with legacy schema + ( + "test_event_sensor_legacy_schema.yaml", + {"trigger": {"platform": "event", "event_type": "override"}}, + ), + # Override a blueprint with legacy schema with modern schema + ( + "test_event_sensor_legacy_schema.yaml", + {"triggers": {"platform": "event", "event_type": "override"}}, + ), + ], +) +async def test_blueprint_template_override( + hass: HomeAssistant, blueprint: str, override: dict +) -> None: + """Test blueprint template where the template config overrides the blueprint.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "use_blueprint": { + "path": blueprint, + "input": { + "event_type": "my_custom_event", + "event_data": {"foo": "bar"}, + }, + }, + "name": "My Custom Event", + } + | override, + ] + }, + ) + await hass.async_block_till_done() + + date_state = hass.states.get("sensor.my_custom_event") + assert date_state is not None + assert date_state.state == "unknown" + + context = Context() + now = dt_util.utcnow() + with patch("homeassistant.util.dt.now", return_value=now): + hass.bus.async_fire( + "my_custom_event", {"foo": "bar", "beer": 2}, context=context + ) + await hass.async_block_till_done() + + date_state = hass.states.get("sensor.my_custom_event") + assert date_state is not None + assert date_state.state == "unknown" + + context = Context() + now = dt_util.utcnow() + with patch("homeassistant.util.dt.now", return_value=now): + hass.bus.async_fire("override", {"foo": "bar", "beer": 2}, context=context) + await hass.async_block_till_done() + + date_state = hass.states.get("sensor.my_custom_event") + assert date_state is not None + assert date_state.state == now.isoformat(timespec="seconds") + data = date_state.attributes.get("data") + assert data is not None + assert data != "" + assert data.get("foo") == "bar" + assert data.get("beer") == 2 + + inverted_foo_template = template.helpers.blueprint_in_template( + hass, "sensor.my_custom_event" + ) + assert inverted_foo_template == blueprint + + inverted_binary_sensor_blueprint_entity_ids = ( + template.helpers.templates_with_blueprint(hass, blueprint) + ) + assert len(inverted_binary_sensor_blueprint_entity_ids) == 1 + + with pytest.raises(BlueprintInUse): + await template.async_get_blueprints(hass).async_remove_blueprint(blueprint) + + async def test_domain_blueprint(hass: HomeAssistant) -> None: """Test DomainBlueprint services.""" reload_handler_calls = async_mock_service(hass, DOMAIN, SERVICE_RELOAD) diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 21d740b165b..2c4e24ddf71 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value from tests.typing import WebSocketGenerator SWITCH_BEFORE_OPTIONS = { @@ -407,17 +407,6 @@ async def test_config_flow_device( } -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # If the desired key is missing from the schema, return None - return None - - @pytest.mark.parametrize( ( "template_type", @@ -608,7 +597,7 @@ async def test_options( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == template_type - assert get_suggested( + assert get_schema_suggested_value( result["data_schema"].schema, key_template ) == old_state_template.get(key_template) assert "name" not in result["data_schema"].schema @@ -655,8 +644,10 @@ async def test_options( assert result["type"] is FlowResultType.FORM assert result["step_id"] == template_type - assert get_suggested(result["data_schema"].schema, "name") is None - assert get_suggested(result["data_schema"].schema, key_template) is None + assert get_schema_suggested_value(result["data_schema"].schema, "name") is None + assert ( + get_schema_suggested_value(result["data_schema"].schema, key_template) is None + ) @pytest.mark.parametrize( diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 668592e388b..5f28a977867 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -4,7 +4,7 @@ from typing import Any import pytest -from homeassistant import setup +from homeassistant.components import cover, template from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, @@ -29,658 +29,776 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from .conftest import ConfigurationStyle + from tests.common import assert_setup_component -ENTITY_COVER = "cover.test_template_cover" +TEST_OBJECT_ID = "test_template_cover" +TEST_ENTITY_ID = f"cover.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "cover.test_state" - -OPEN_CLOSE_COVER_CONFIG = { - "open_cover": { - "service": "test.automation", - "data_template": { - "action": "open_cover", - "caller": "{{ this.entity_id }}", - }, - }, - "close_cover": { - "service": "test.automation", - "data_template": { - "action": "close_cover", - "caller": "{{ this.entity_id }}", - }, +OPEN_COVER = { + "service": "test.automation", + "data_template": { + "action": "open_cover", + "caller": "{{ this.entity_id }}", }, } +CLOSE_COVER = { + "service": "test.automation", + "data_template": { + "action": "close_cover", + "caller": "{{ this.entity_id }}", + }, +} -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - ("config", "states"), - [ - ( - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "{{ states.cover.test_state.state }}", - } - }, - } - }, - [ - ("cover.test_state", CoverState.OPEN, CoverState.OPEN, {}, -1, ""), - ("cover.test_state", CoverState.CLOSED, CoverState.CLOSED, {}, -1, ""), - ( - "cover.test_state", - CoverState.OPENING, - CoverState.OPENING, - {}, - -1, - "", - ), - ( - "cover.test_state", - CoverState.CLOSING, - CoverState.CLOSING, - {}, - -1, - "", - ), - ( - "cover.test_state", - "dog", - STATE_UNKNOWN, - {}, - -1, - "Received invalid cover is_on state: dog", - ), - ("cover.test_state", CoverState.OPEN, CoverState.OPEN, {}, -1, ""), - ( - "cover.test_state", - "cat", - STATE_UNKNOWN, - {}, - -1, - "Received invalid cover is_on state: cat", - ), - ("cover.test_state", CoverState.CLOSED, CoverState.CLOSED, {}, -1, ""), - ( - "cover.test_state", - "bear", - STATE_UNKNOWN, - {}, - -1, - "Received invalid cover is_on state: bear", - ), - ], - ), - ( - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "position_template": ( - "{{ states.cover.test.attributes.position }}" - ), - "value_template": "{{ states.cover.test_state.state }}", - } - }, - } - }, - [ - ("cover.test_state", CoverState.OPEN, STATE_UNKNOWN, {}, -1, ""), - ("cover.test_state", CoverState.CLOSED, STATE_UNKNOWN, {}, -1, ""), - ( - "cover.test_state", - CoverState.OPENING, - CoverState.OPENING, - {}, - -1, - "", - ), - ( - "cover.test_state", - CoverState.CLOSING, - CoverState.CLOSING, - {}, - -1, - "", - ), - ( - "cover.test", - CoverState.CLOSED, - CoverState.CLOSING, - {"position": 0}, - 0, - "", - ), - ("cover.test_state", CoverState.OPEN, CoverState.CLOSED, {}, -1, ""), - ( - "cover.test", - CoverState.CLOSED, - CoverState.OPEN, - {"position": 10}, - 10, - "", - ), - ( - "cover.test_state", - "dog", - CoverState.OPEN, - {}, - -1, - "Received invalid cover is_on state: dog", - ), - ], - ), - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_template_state_text( - hass: HomeAssistant, states, caplog: pytest.LogCaptureFixture +SET_COVER_POSITION = { + "service": "test.automation", + "data_template": { + "action": "set_cover_position", + "caller": "{{ this.entity_id }}", + "position": "{{ position }}", + }, +} + +SET_COVER_TILT_POSITION = { + "service": "test.automation", + "data_template": { + "action": "set_cover_tilt_position", + "caller": "{{ this.entity_id }}", + "tilt_position": "{{ tilt }}", + }, +} + +COVER_ACTIONS = { + "open_cover": OPEN_COVER, + "close_cover": CLOSE_COVER, +} +NAMED_COVER_ACTIONS = { + **COVER_ACTIONS, + "name": TEST_OBJECT_ID, +} +UNIQUE_ID_CONFIG = { + **COVER_ACTIONS, + "unique_id": "not-so-unique-anymore", +} + + +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, cover_config: dict[str, Any] ) -> None: - """Test the state text of a template.""" - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_UNKNOWN + """Do setup of cover integration via legacy format.""" + config = {"cover": {"platform": "template", "covers": cover_config}} - for entity, set_state, test_state, attr, pos, text in states: - hass.states.async_set(entity, set_state, attributes=attr) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == test_state - if pos >= 0: - assert state.attributes.get("current_position") == pos - assert text in caplog.text - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - ("config", "entity", "set_state", "test_state", "attr"), - [ - ( - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "position_template": ( - "{{ states.cover.test.attributes.position }}" - ), - "value_template": "{{ states.cover.test_state.state }}", - } - }, - } - }, - "cover.test_state", - "", - STATE_UNKNOWN, - {}, - ), - ( - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "position_template": ( - "{{ states.cover.test.attributes.position }}" - ), - "value_template": "{{ states.cover.test_state.state }}", - } - }, - } - }, - "cover.test_state", - None, - STATE_UNKNOWN, - {}, - ), - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_template_state_text_ignored_if_none_or_empty( - hass: HomeAssistant, - entity: str, - set_state: str, - test_state: str, - attr: dict[str, Any], - caplog: pytest.LogCaptureFixture, -) -> None: - """Test ignoring an empty state text of a template.""" - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_UNKNOWN - - hass.states.async_set(entity, set_state, attributes=attr) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == test_state - assert "ERROR" not in caplog.text - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "{{ 1 == 1 }}", - } - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_template_state_boolean(hass: HomeAssistant) -> None: - """Test the value_template attribute.""" - state = hass.states.get("cover.test_template_cover") - assert state.state == CoverState.OPEN - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "position_template": ( - "{{ states.cover.test.attributes.position }}" - ), - } - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_template_position( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test the position_template attribute.""" - hass.states.async_set("cover.test", CoverState.OPEN) - attrs = {} - - for set_state, pos, test_state in ( - (CoverState.CLOSED, 42, CoverState.OPEN), - (CoverState.OPEN, 0.0, CoverState.CLOSED), - (CoverState.CLOSED, None, STATE_UNKNOWN), - ): - attrs["position"] = pos - hass.states.async_set("cover.test", set_state, attributes=attrs) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_position") == pos - assert state.state == test_state - assert "ValueError" not in caplog.text - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "optimistic": False, - } - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_template_not_optimistic(hass: HomeAssistant) -> None: - """Test the is_closed attribute.""" - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_UNKNOWN - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - ("config", "tilt_position"), - [ - ( - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "{{ 1 == 1 }}", - "tilt_template": "{{ 42 }}", - } - }, - } - }, - 42.0, - ), - ( - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "{{ 1 == 1 }}", - "tilt_template": "{{ None }}", - } - }, - } - }, - None, - ), - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_template_tilt(hass: HomeAssistant, tilt_position: float | None) -> None: - """Test the tilt_template attribute.""" - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_tilt_position") == tilt_position - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "position_template": "{{ -1 }}", - "tilt_template": "{{ 110 }}", - } - }, - } - }, - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "position_template": "{{ on }}", - "tilt_template": ( - "{% if states.cover.test_state.state %}" - "on" - "{% else %}" - "off" - "{% endif %}" - ), - }, - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_template_out_of_bounds(hass: HomeAssistant) -> None: - """Test template out-of-bounds condition.""" - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_tilt_position") is None - assert state.attributes.get("current_position") is None - - -@pytest.mark.parametrize(("count", "domain"), [(0, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": {"test_template_cover": {"value_template": "{{ 1 == 1 }}"}}, - } - }, - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - "value_template": "{{ 1 == 1 }}", - "open_cover": { - "service": "test.automation", - "data_template": { - "action": "open_cover", - "caller": "{{ this.entity_id }}", - }, - }, - } - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_template_open_or_position( - hass: HomeAssistant, caplog_setup_text -) -> None: - """Test that at least one of open_cover or set_position is used.""" - assert hass.states.async_all("cover") == [] - assert "Invalid config for 'cover' from integration 'template'" in caplog_setup_text - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "position_template": "{{ 0 }}", - } - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_open_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test the open_cover command.""" - state = hass.states.get("cover.test_template_cover") - assert state.state == CoverState.CLOSED - - await hass.services.async_call( - COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - - assert len(calls) == 1 - assert calls[0].data["action"] == "open_cover" - assert calls[0].data["caller"] == "cover.test_template_cover" - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "position_template": "{{ 100 }}", - "stop_cover": { - "service": "test.automation", - "data_template": { - "action": "stop_cover", - "caller": "{{ this.entity_id }}", - }, - }, - } - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_close_stop_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test the close-cover and stop_cover commands.""" - state = hass.states.get("cover.test_template_cover") - assert state.state == CoverState.OPEN - - await hass.services.async_call( - COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - - await hass.services.async_call( - COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - - assert len(calls) == 2 - assert calls[0].data["action"] == "close_cover" - assert calls[0].data["caller"] == "cover.test_template_cover" - assert calls[1].data["action"] == "stop_cover" - assert calls[1].data["caller"] == "cover.test_template_cover" - - -@pytest.mark.parametrize(("count", "domain"), [(1, "input_number")]) -@pytest.mark.parametrize( - "config", - [ - {"input_number": {"test": {"min": "0", "max": "100", "initial": "42"}}}, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_set_position(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test the set_position command.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( + with assert_setup_component(count, cover.DOMAIN): + assert await async_setup_component( hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "set_cover_position": { - "service": "test.automation", - "data_template": { - "action": "set_cover_position", - "caller": "{{ this.entity_id }}", - "position": "{{ position }}", - }, - }, - } - }, - } - }, + cover.DOMAIN, + config, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_format( + hass: HomeAssistant, count: int, cover_config: dict[str, Any] +) -> None: + """Do setup of cover integration via modern format.""" + config = {"template": {"cover": cover_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, ) await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() - state = hass.states.async_set("input_number.test", 42) + +async def async_setup_cover_config( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + cover_config: dict[str, Any], +) -> None: + """Do setup of cover integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, cover_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, cover_config) + + +@pytest.fixture +async def setup_cover( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + cover_config: dict[str, Any], +) -> None: + """Do setup of cover integration.""" + await async_setup_cover_config(hass, count, style, cover_config) + + +@pytest.fixture +async def setup_state_cover( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, +): + """Do setup of cover integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **COVER_ACTIONS, + "value_template": state_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_COVER_ACTIONS, + "state": state_template, + }, + ) + + +@pytest.fixture +async def setup_position_cover( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + position_template: str, +): + """Do setup of cover integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **COVER_ACTIONS, + "position_template": position_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_COVER_ACTIONS, + "position": position_template, + }, + ) + + +@pytest.fixture +async def setup_single_attribute_state_cover( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, + attribute: str, + attribute_template: str, +) -> None: + """Do setup of cover integration testing a single attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **COVER_ACTIONS, + "value_template": state_template, + **extra, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_COVER_ACTIONS, + "state": state_template, + **extra, + }, + ) + + +@pytest.mark.parametrize( + ("count", "state_template"), [(1, "{{ states.cover.test_state.state }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("set_state", "test_state", "text"), + [ + (CoverState.OPEN, CoverState.OPEN, ""), + (CoverState.CLOSED, CoverState.CLOSED, ""), + (CoverState.OPENING, CoverState.OPENING, ""), + (CoverState.CLOSING, CoverState.CLOSING, ""), + ("dog", STATE_UNKNOWN, "Received invalid cover is_on state: dog"), + ("cat", STATE_UNKNOWN, "Received invalid cover is_on state: cat"), + ("bear", STATE_UNKNOWN, "Received invalid cover is_on state: bear"), + ], +) +async def test_template_state_text( + hass: HomeAssistant, + set_state: str, + test_state: str, + text: str, + caplog: pytest.LogCaptureFixture, + setup_state_cover, +) -> None: + """Test the state text of a template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + hass.states.async_set(TEST_STATE_ENTITY_ID, set_state) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == test_state + assert text in caplog.text + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template"), + [ + ( + 1, + "{{ states.cover.test_state.state }}", + "{{ states.cover.test_position.attributes.position }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "position_template"), + (ConfigurationStyle.MODERN, "position"), + ], +) +@pytest.mark.parametrize( + "states", + [ + ( + [ + (TEST_STATE_ENTITY_ID, CoverState.OPEN, STATE_UNKNOWN, "", None), + (TEST_STATE_ENTITY_ID, CoverState.CLOSED, STATE_UNKNOWN, "", None), + ( + TEST_STATE_ENTITY_ID, + CoverState.OPENING, + CoverState.OPENING, + "", + None, + ), + ( + TEST_STATE_ENTITY_ID, + CoverState.CLOSING, + CoverState.CLOSING, + "", + None, + ), + ("cover.test_position", CoverState.CLOSED, CoverState.CLOSING, "", 0), + (TEST_STATE_ENTITY_ID, CoverState.OPEN, CoverState.CLOSED, "", None), + ("cover.test_position", CoverState.CLOSED, CoverState.OPEN, "", 10), + ( + TEST_STATE_ENTITY_ID, + "dog", + CoverState.OPEN, + "Received invalid cover is_on state: dog", + None, + ), + ] + ) + ], +) +async def test_template_state_text_with_position( + hass: HomeAssistant, + states: list[tuple[str, str, str, int | None]], + caplog: pytest.LogCaptureFixture, + setup_single_attribute_state_cover, +) -> None: + """Test the state of a position template in order.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + for test_entity, set_state, test_state, text, position in states: + attrs = {"position": position} if position is not None else {} + + hass.states.async_set(test_entity, set_state, attrs) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == test_state + if position is not None: + assert state.attributes.get("current_position") == position + assert text in caplog.text + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template"), + [ + ( + 1, + "{{ states.cover.test_state.state }}", + "{{ states.cover.test_position.attributes.position }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "position_template"), + (ConfigurationStyle.MODERN, "position"), + ], +) +@pytest.mark.parametrize( + "set_state", + [ + "", + None, + ], +) +async def test_template_state_text_ignored_if_none_or_empty( + hass: HomeAssistant, + set_state: str, + caplog: pytest.LogCaptureFixture, + setup_single_attribute_state_cover, +) -> None: + """Test ignoring an empty state text of a template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + hass.states.async_set(TEST_STATE_ENTITY_ID, set_state) + await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + assert "ERROR" not in caplog.text + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 1 }}")]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +async def test_template_state_boolean(hass: HomeAssistant, setup_state_cover) -> None: + """Test the value_template attribute.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == CoverState.OPEN + + +@pytest.mark.parametrize( + ("count", "position_template"), + [(1, "{{ states.cover.test_state.attributes.position }}")], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("test_state", "position", "expected"), + [ + (CoverState.CLOSED, 42, CoverState.OPEN), + (CoverState.OPEN, 0.0, CoverState.CLOSED), + (CoverState.CLOSED, None, STATE_UNKNOWN), + ], +) +async def test_template_position( + hass: HomeAssistant, + test_state: str, + position: int | None, + expected: str, + caplog: pytest.LogCaptureFixture, + setup_position_cover, +) -> None: + """Test the position_template attribute.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, CoverState.OPEN) + await hass.async_block_till_done() + + hass.states.async_set( + TEST_STATE_ENTITY_ID, test_state, attributes={"position": position} + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("current_position") == position + assert state.state == expected + assert "ValueError" not in caplog.text + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "cover_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "test_template_cover": { + **COVER_ACTIONS, + "optimistic": False, + } + }, + ), + ( + ConfigurationStyle.MODERN, + { + **NAMED_COVER_ACTIONS, + "optimistic": False, + }, + ), + ], +) +async def test_template_not_optimistic(hass: HomeAssistant, setup_cover) -> None: + """Test the is_closed attribute.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 1 }}")]) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + ( + ConfigurationStyle.LEGACY, + "tilt_template", + ), + ( + ConfigurationStyle.MODERN, + "tilt", + ), + ], +) +@pytest.mark.parametrize( + ("attribute_template", "tilt_position"), + [ + ("{{ 1 }}", 1.0), + ("{{ 42 }}", 42.0), + ("{{ 100 }}", 100.0), + ("{{ None }}", None), + ("{{ 110 }}", None), + ("{{ -1 }}", None), + ("{{ 'on' }}", None), + ], +) +async def test_template_tilt( + hass: HomeAssistant, tilt_position: float | None, setup_single_attribute_state_cover +) -> None: + """Test tilt in and out-of-bound conditions.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("current_tilt_position") == tilt_position + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 1 }}")]) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + ( + ConfigurationStyle.LEGACY, + "position_template", + ), + ( + ConfigurationStyle.MODERN, + "position", + ), + ], +) +@pytest.mark.parametrize( + "attribute_template", + [ + "{{ -1 }}", + "{{ 110 }}", + "{{ 'on' }}", + "{{ 'off' }}", + ], +) +async def test_position_out_of_bounds( + hass: HomeAssistant, setup_single_attribute_state_cover +) -> None: + """Test position out-of-bounds condition.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("current_position") is None + + +@pytest.mark.parametrize("count", [0]) +@pytest.mark.parametrize( + ("style", "cover_config", "error"), + [ + ( + ConfigurationStyle.LEGACY, + { + "test_template_cover": { + "value_template": "{{ 1 == 1 }}", + } + }, + "Invalid config for 'cover' from integration 'template'", + ), + ( + ConfigurationStyle.LEGACY, + { + "test_template_cover": { + "value_template": "{{ 1 == 1 }}", + "open_cover": OPEN_COVER, + } + }, + "Invalid config for 'cover' from integration 'template'", + ), + ( + ConfigurationStyle.MODERN, + { + "name": TEST_OBJECT_ID, + "state": "{{ 1 == 1 }}", + }, + "Invalid config for 'template': must contain at least one of open_cover, set_cover_position.", + ), + ( + ConfigurationStyle.MODERN, + { + "name": TEST_OBJECT_ID, + "state": "{{ 1 == 1 }}", + "open_cover": OPEN_COVER, + }, + "Invalid config for 'template': some but not all values in the same group of inclusion 'open_or_close'", + ), + ], +) +async def test_template_open_or_position( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + cover_config: dict[str, Any], + error: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that at least one of open_cover or set_position is used.""" + await async_setup_cover_config(hass, count, style, cover_config) + assert hass.states.async_all("cover") == [] + assert error in caplog.text + + +@pytest.mark.parametrize( + ("count", "position_template"), + [(1, "{{ 0 }}")], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +async def test_open_action( + hass: HomeAssistant, setup_position_cover, calls: list[ServiceCall] +) -> None: + """Test the open_cover command.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == CoverState.CLOSED + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["action"] == "open_cover" + assert calls[0].data["caller"] == TEST_ENTITY_ID + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "cover_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "test_template_cover": { + **COVER_ACTIONS, + "position_template": "{{ 100 }}", + "stop_cover": { + "service": "test.automation", + "data_template": { + "action": "stop_cover", + "caller": "{{ this.entity_id }}", + }, + }, + } + }, + ), + ( + ConfigurationStyle.MODERN, + { + **NAMED_COVER_ACTIONS, + "position": "{{ 100 }}", + "stop_cover": { + "service": "test.automation", + "data_template": { + "action": "stop_cover", + "caller": "{{ this.entity_id }}", + }, + }, + }, + ), + ], +) +async def test_close_stop_action( + hass: HomeAssistant, setup_cover, calls: list[ServiceCall] +) -> None: + """Test the close-cover and stop_cover commands.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == CoverState.OPEN + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(calls) == 2 + assert calls[0].data["action"] == "close_cover" + assert calls[0].data["caller"] == TEST_ENTITY_ID + assert calls[1].data["action"] == "stop_cover" + assert calls[1].data["caller"] == TEST_ENTITY_ID + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "cover_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "test_template_cover": { + "set_cover_position": SET_COVER_POSITION, + } + }, + ), + ( + ConfigurationStyle.MODERN, + { + "name": TEST_OBJECT_ID, + "set_cover_position": SET_COVER_POSITION, + }, + ), + ], +) +async def test_set_position( + hass: HomeAssistant, setup_cover, calls: list[ServiceCall] +) -> None: + """Test the set_position command.""" + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_UNKNOWN await hass.services.async_call( - COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_position") == 100.0 assert len(calls) == 1 assert calls[-1].data["action"] == "set_cover_position" - assert calls[-1].data["caller"] == "cover.test_template_cover" + assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["position"] == 100 await hass.services.async_call( - COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_position") == 0.0 assert len(calls) == 2 assert calls[-1].data["action"] == "set_cover_position" - assert calls[-1].data["caller"] == "cover.test_template_cover" + assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["position"] == 0 await hass.services.async_call( - COVER_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_position") == 100.0 assert len(calls) == 3 assert calls[-1].data["action"] == "set_cover_position" - assert calls[-1].data["caller"] == "cover.test_template_cover" + assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["position"] == 100 await hass.services.async_call( - COVER_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_position") == 0.0 assert len(calls) == 4 assert calls[-1].data["action"] == "set_cover_position" - assert calls[-1].data["caller"] == "cover.test_template_cover" + assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["position"] == 0 await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 25}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_POSITION: 25}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_position") == 25.0 assert len(calls) == 5 assert calls[-1].data["action"] == "set_cover_position" - assert calls[-1].data["caller"] == "cover.test_template_cover" + assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["position"] == 25 -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + ("style", "cover_config"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "set_cover_tilt_position": { - "service": "test.automation", - "data_template": { - "action": "set_cover_tilt_position", - "caller": "{{ this.entity_id }}", - "tilt_position": "{{ tilt }}", - }, - }, - } - }, - } - }, + ( + ConfigurationStyle.LEGACY, + { + "test_template_cover": { + **COVER_ACTIONS, + "set_cover_tilt_position": SET_COVER_TILT_POSITION, + } + }, + ), + ( + ConfigurationStyle.MODERN, + { + **NAMED_COVER_ACTIONS, + "set_cover_tilt_position": SET_COVER_TILT_POSITION, + }, + ), ], ) @pytest.mark.parametrize( @@ -688,20 +806,20 @@ async def test_set_position(hass: HomeAssistant, calls: list[ServiceCall]) -> No [ ( SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_TILT_POSITION: 42}, 42, ), - (SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, 100), - (SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, 0), + (SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, 100), + (SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, 0), ], ) -@pytest.mark.usefixtures("start_ha") async def test_set_tilt_position( hass: HomeAssistant, service, attr, - calls: list[ServiceCall], tilt_position, + setup_cover, + calls: list[ServiceCall], ) -> None: """Test the set_tilt_position command.""" await hass.services.async_call( @@ -714,42 +832,46 @@ async def test_set_tilt_position( assert len(calls) == 1 assert calls[-1].data["action"] == "set_cover_tilt_position" - assert calls[-1].data["caller"] == "cover.test_template_cover" + assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["tilt_position"] == tilt_position -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + ("style", "cover_config"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - "set_cover_position": {"service": "test.automation"} - } - }, - } - }, + ( + ConfigurationStyle.LEGACY, + { + "test_template_cover": { + "set_cover_position": SET_COVER_POSITION, + } + }, + ), + ( + ConfigurationStyle.MODERN, + { + "name": TEST_OBJECT_ID, + "set_cover_position": SET_COVER_POSITION, + }, + ), ], ) -@pytest.mark.usefixtures("start_ha") async def test_set_position_optimistic( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, setup_cover, calls: list[ServiceCall] ) -> None: """Test optimistic position mode.""" - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_position") is None await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 42}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_POSITION: 42}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_position") == 42.0 for service, test_state in ( @@ -759,47 +881,53 @@ async def test_set_position_optimistic( (SERVICE_TOGGLE, CoverState.OPEN), ): await hass.services.async_call( - COVER_DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, service, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == test_state -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + ("style", "cover_config"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ 100 }}", - "set_cover_position": {"service": "test.automation"}, - "set_cover_tilt_position": {"service": "test.automation"}, - } - }, - } - }, + ( + ConfigurationStyle.LEGACY, + { + "test_template_cover": { + "position_template": "{{ 100 }}", + "set_cover_position": SET_COVER_POSITION, + "set_cover_tilt_position": SET_COVER_TILT_POSITION, + } + }, + ), + ( + ConfigurationStyle.MODERN, + { + "name": TEST_OBJECT_ID, + "position": "{{ 100 }}", + "set_cover_position": SET_COVER_POSITION, + "set_cover_tilt_position": SET_COVER_TILT_POSITION, + }, + ), ], ) -@pytest.mark.usefixtures("calls", "start_ha") async def test_set_tilt_position_optimistic( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, setup_cover, calls: list[ServiceCall] ) -> None: """Test the optimistic tilt_position mode.""" - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_tilt_position") is None await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_TILT_POSITION: 42}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_tilt_position") == 42.0 for service, pos in ( @@ -809,157 +937,140 @@ async def test_set_tilt_position_optimistic( (SERVICE_TOGGLE_COVER_TILT, 100.0), ): await hass.services.async_call( - COVER_DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, service, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_tilt_position") == pos -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "{{ states.cover.test_state.state }}", - "icon_template": ( - "{% if states.cover.test_state.state %}mdi:check{% endif %}" - ), - } - }, - } - }, + ( + 1, + "{{ states.cover.test_state.state }}", + "{% if states.cover.test_state.state %}mdi:check{% endif %}", + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_icon_template(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "icon_template"), + (ConfigurationStyle.MODERN, "icon"), + ], +) +async def test_icon_template( + hass: HomeAssistant, setup_single_attribute_state_cover +) -> None: """Test icon template.""" - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("icon") == "" state = hass.states.async_set("cover.test_state", CoverState.OPEN) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["icon"] == "mdi:check" -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "{{ states.cover.test_state.state }}", - "entity_picture_template": ( - "{% if states.cover.test_state.state %}" - "/local/cover.png" - "{% endif %}" - ), - } - }, - } - }, + ( + 1, + "{{ states.cover.test_state.state }}", + "{% if states.cover.test_state.state %}/local/cover.png{% endif %}", + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_entity_picture_template(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "entity_picture_template"), + (ConfigurationStyle.MODERN, "picture"), + ], +) +async def test_entity_picture_template( + hass: HomeAssistant, setup_single_attribute_state_cover +) -> None: """Test icon template.""" - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("entity_picture") == "" state = hass.states.async_set("cover.test_state", CoverState.OPEN) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["entity_picture"] == "/local/cover.png" -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "open", - "availability_template": ( - "{{ is_state('availability_state.state','on') }}" - ), - } - }, - } - }, + ( + 1, + "{{ 1 == 1 }}", + "{{ is_state('availability_state.state','on') }}", + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_availability_template(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + ], +) +async def test_availability_template( + hass: HomeAssistant, setup_single_attribute_state_cover +) -> None: """Test availability template.""" hass.states.async_set("availability_state.state", STATE_OFF) await hass.async_block_till_done() - assert hass.states.get("cover.test_template_cover").state == STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE hass.states.async_set("availability_state.state", STATE_ON) await hass.async_block_till_done() - assert hass.states.get("cover.test_template_cover").state != STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + ("config", "domain"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "open", - } - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_availability_without_availability_template(hass: HomeAssistant) -> None: - """Test that component is available if there is no.""" - state = hass.states.get("cover.test_template_cover") - assert state.state != STATE_UNAVAILABLE - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "availability_template": "{{ x - 12 }}", - "value_template": "open", - } - }, - } - }, + ( + { + COVER_DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + **COVER_ACTIONS, + "availability_template": "{{ x - 12 }}", + "value_template": "open", + } + }, + } + }, + cover.DOMAIN, + ), + ( + { + "template": { + "cover": { + **NAMED_COVER_ACTIONS, + "state": "{{ true }}", + "availability": "{{ x - 12 }}", + }, + } + }, + template.DOMAIN, + ), ], ) @pytest.mark.usefixtures("start_ha") @@ -967,111 +1078,142 @@ async def test_invalid_availability_template_keeps_component_available( hass: HomeAssistant, caplog_setup_text ) -> None: """Test that an invalid availability keeps the device available.""" - assert hass.states.get("cover.test_template_cover") != STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID) != STATE_UNAVAILABLE assert "UndefinedError: 'x' is undefined" in caplog_setup_text -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "{{ states.cover.test_state.state }}", - "device_class": "door", - } - }, - } - }, - ], + ("count", "state_template", "attribute", "attribute_template"), + [(1, "{{ 1 == 1 }}", "device_class", "door")], ) -@pytest.mark.usefixtures("start_ha") -async def test_device_class(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], +) +async def test_device_class( + hass: HomeAssistant, setup_single_attribute_state_cover +) -> None: """Test device class.""" - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("device_class") == "door" -@pytest.mark.parametrize(("count", "domain"), [(0, COVER_DOMAIN)]) @pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "{{ states.cover.test_state.state }}", - "device_class": "barnacle_bill", - } - }, - } - }, - ], + ("count", "state_template", "attribute", "attribute_template"), + [(0, "{{ 1 == 1 }}", "device_class", "barnacle_bill")], ) -@pytest.mark.usefixtures("start_ha") -async def test_invalid_device_class(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], +) +async def test_invalid_device_class( + hass: HomeAssistant, setup_single_attribute_state_cover +) -> None: """Test device class.""" - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert not state -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + ("cover_config", "style"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover_01": { - **OPEN_CLOSE_COVER_CONFIG, - "unique_id": "not-so-unique-anymore", - "value_template": "{{ true }}", - }, - "test_template_cover_02": { - **OPEN_CLOSE_COVER_CONFIG, - "unique_id": "not-so-unique-anymore", - "value_template": "{{ false }}", - }, + ( + { + "test_template_cover_01": UNIQUE_ID_CONFIG, + "test_template_cover_02": UNIQUE_ID_CONFIG, + }, + ConfigurationStyle.LEGACY, + ), + ( + [ + { + "name": "test_template_cover_01", + **UNIQUE_ID_CONFIG, }, - } - }, + { + "name": "test_template_cover_02", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.MODERN, + ), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id(hass: HomeAssistant, setup_cover) -> None: """Test unique_id option only creates one cover per id.""" assert len(hass.states.async_all()) == 1 -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "garage_door": { - **OPEN_CLOSE_COVER_CONFIG, - "friendly_name": "Garage Door", - "value_template": ( - "{{ is_state('binary_sensor.garage_door_sensor', 'off') }}" - ), - }, +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a template unique_id propagates to switch unique_ids.""" + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + { + "template": { + "unique_id": "x", + "cover": [ + { + **COVER_ACTIONS, + "name": "test_a", + "unique_id": "a", + "state": "{{ true }}", + }, + { + **COVER_ACTIONS, + "name": "test_b", + "unique_id": "b", + "state": "{{ true }}", + }, + ], }, - } - }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all("cover")) == 2 + + entry = entity_registry.async_get("cover.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("cover.test_b") + assert entry + assert entry.unique_id == "x-b" + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "cover_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "garage_door": { + **COVER_ACTIONS, + "friendly_name": "Garage Door", + "value_template": "{{ is_state('binary_sensor.garage_door_sensor', 'off') }}", + }, + }, + ), + ( + ConfigurationStyle.MODERN, + { + "name": "Garage Door", + **COVER_ACTIONS, + "state": "{{ is_state('binary_sensor.garage_door_sensor', 'off') }}", + }, + ), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_state_gets_lowercased(hass: HomeAssistant) -> None: +async def test_state_gets_lowercased(hass: HomeAssistant, setup_cover) -> None: """Test True/False is lowercased.""" hass.states.async_set("binary_sensor.garage_door_sensor", "off") @@ -1085,41 +1227,27 @@ async def test_state_gets_lowercased(hass: HomeAssistant) -> None: assert hass.states.get("cover.garage_door").state == CoverState.CLOSED -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "office": { - "icon_template": """{% if is_state('cover.office', 'open') %} - mdi:window-shutter-open - {% else %} - mdi:window-shutter - {% endif %}""", - "open_cover": { - "service": "switch.turn_on", - "entity_id": "switch.office_blinds_up", - }, - "close_cover": { - "service": "switch.turn_on", - "entity_id": "switch.office_blinds_down", - }, - "stop_cover": { - "service": "switch.turn_on", - "entity_id": "switch.office_blinds_up", - }, - }, - }, - } - }, + ( + 1, + "{{ states.cover.test_state.state }}", + "mdi:window-shutter{{ '-open' if is_state('cover.test_template_cover', 'open') else '' }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "icon_template"), + (ConfigurationStyle.MODERN, "icon"), ], ) -@pytest.mark.usefixtures("start_ha") async def test_self_referencing_icon_with_no_template_is_not_a_loop( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + setup_single_attribute_state_cover, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a self referencing icon with no value template is not a loop.""" assert len(hass.states.async_all()) == 1 diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index e92bc82f5ae..dac97931fa7 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -1,9 +1,12 @@ """The tests for the Template fan platform.""" +from typing import Any + import pytest import voluptuous as vol from homeassistant import setup +from homeassistant.components import fan from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, @@ -17,11 +20,15 @@ from homeassistant.components.fan import ( ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.setup import async_setup_component + +from .conftest import ConfigurationStyle from tests.common import assert_setup_component from tests.components.fan import common -_TEST_FAN = "fan.test_fan" +_TEST_OBJECT_ID = "test_fan" +_TEST_FAN = f"fan.{_TEST_OBJECT_ID}" # Represent for fan's state _STATE_INPUT_BOOLEAN = "input_boolean.state" # Represent for fan's state @@ -36,6 +43,169 @@ _OSC_INPUT = "input_select.osc" _DIRECTION_INPUT_SELECT = "input_select.direction" +OPTIMISTIC_ON_OFF_CONFIG = { + "turn_on": { + "service": "test.automation", + "data": { + "action": "turn_on", + "caller": "{{ this.entity_id }}", + }, + }, + "turn_off": { + "service": "test.automation", + "data": { + "action": "turn_off", + "caller": "{{ this.entity_id }}", + }, + }, +} + + +PERCENTAGE_ACTION = { + "set_percentage": { + "action": "test.automation", + "data": { + "action": "set_percentage", + "percentage": "{{ percentage }}", + "caller": "{{ this.entity_id }}", + }, + }, +} +OPTIMISTIC_PERCENTAGE_CONFIG = { + **OPTIMISTIC_ON_OFF_CONFIG, + **PERCENTAGE_ACTION, +} + +PRESET_MODE_ACTION = { + "set_preset_mode": { + "action": "test.automation", + "data": { + "action": "set_preset_mode", + "preset_mode": "{{ preset_mode }}", + "caller": "{{ this.entity_id }}", + }, + }, +} +OPTIMISTIC_PRESET_MODE_CONFIG = { + **OPTIMISTIC_ON_OFF_CONFIG, + **PRESET_MODE_ACTION, +} +OPTIMISTIC_PRESET_MODE_CONFIG2 = { + **OPTIMISTIC_PRESET_MODE_CONFIG, + "preset_modes": ["auto", "low", "medium", "high"], +} + +OSCILLATE_ACTION = { + "set_oscillating": { + "action": "test.automation", + "data": { + "action": "set_oscillating", + "oscillating": "{{ oscillating }}", + "caller": "{{ this.entity_id }}", + }, + }, +} +OPTIMISTIC_OSCILLATE_CONFIG = { + **OPTIMISTIC_ON_OFF_CONFIG, + **OSCILLATE_ACTION, +} + +DIRECTION_ACTION = { + "set_direction": { + "action": "test.automation", + "data": { + "action": "set_direction", + "direction": "{{ direction }}", + "caller": "{{ this.entity_id }}", + }, + }, +} +OPTIMISTIC_DIRECTION_CONFIG = { + **OPTIMISTIC_ON_OFF_CONFIG, + **DIRECTION_ACTION, +} + + +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, light_config: dict[str, Any] +) -> None: + """Do setup of fan integration via legacy format.""" + config = {"fan": {"platform": "template", "fans": light_config}} + + with assert_setup_component(count, fan.DOMAIN): + assert await async_setup_component( + hass, + fan.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_legacy_format_with_attribute( + hass: HomeAssistant, + count: int, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of a legacy fan that has a single templated attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + await async_setup_legacy_format( + hass, + count, + { + _TEST_OBJECT_ID: { + **extra_config, + "value_template": "{{ 1 == 1 }}", + **extra, + } + }, + ) + + +@pytest.fixture +async def setup_fan( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + light_config: dict[str, Any], +) -> None: + """Do setup of fan integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, light_config) + + +@pytest.fixture +async def setup_test_fan_with_extra_config( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + fan_config: dict[str, Any], + extra_config: dict[str, Any], +) -> None: + """Do setup of fan integration.""" + config = {_TEST_OBJECT_ID: {**fan_config, **extra_config}} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, config) + + +@pytest.fixture +async def setup_optimistic_fan_attribute( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + extra_config: dict, +) -> None: + """Do setup of a non-optimistic fan with an optimistic attribute.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format_with_attribute( + hass, count, "", "", extra_config + ) + + @pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) @pytest.mark.parametrize( "config", @@ -123,28 +293,21 @@ async def test_wrong_template_config(hass: HomeAssistant) -> None: "platform": "template", "fans": { "test_fan": { - "value_template": """ - {% if is_state('input_boolean.state', 'True') %} - {{ 'on' }} - {% else %} - {{ 'off' }} - {% endif %} - """, + "value_template": "{{ is_state('input_boolean.state', 'True') }}", "percentage_template": ( "{{ states('input_number.percentage') }}" ), + **OPTIMISTIC_ON_OFF_CONFIG, + **PERCENTAGE_ACTION, "preset_mode_template": ( "{{ states('input_select.preset_mode') }}" ), + **PRESET_MODE_ACTION, "oscillating_template": "{{ states('input_select.osc') }}", + **OSCILLATE_ACTION, "direction_template": "{{ states('input_select.direction') }}", + **DIRECTION_ACTION, "speed_count": "3", - "set_percentage": { - "service": "script.fans_set_speed", - "data_template": {"percentage": "{{ percentage }}"}, - }, - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, } }, } @@ -188,8 +351,7 @@ async def test_templates_with_entities(hass: HomeAssistant) -> None: "test_fan": { "value_template": "{{ 'on' }}", "percentage_template": "{{ states('sensor.percentage') }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, + **OPTIMISTIC_PERCENTAGE_CONFIG, }, }, } @@ -215,8 +377,7 @@ async def test_templates_with_entities(hass: HomeAssistant) -> None: "preset_mode_template": ( "{{ states('sensor.preset_mode') }}" ), - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, + **OPTIMISTIC_PRESET_MODE_CONFIG, }, }, } @@ -284,8 +445,7 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: "fans": { "test_fan": { "value_template": "{{ 'unavailable' }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, + **OPTIMISTIC_ON_OFF_CONFIG, } }, } @@ -299,11 +459,12 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: "fans": { "test_fan": { "value_template": "{{ 'on' }}", - "oscillating_template": "{{ 'unavailable' }}", - "direction_template": "{{ 'unavailable' }}", "percentage_template": "{{ 0 }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating_template": "{{ 'unavailable' }}", + **OSCILLATE_ACTION, + "direction_template": "{{ 'unavailable' }}", + **DIRECTION_ACTION, } }, } @@ -317,11 +478,12 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: "fans": { "test_fan": { "value_template": "{{ 'on' }}", - "oscillating_template": "{{ 1 == 1 }}", - "direction_template": "{{ 'forward' }}", "percentage_template": "{{ 66 }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating_template": "{{ 1 == 1 }}", + **OSCILLATE_ACTION, + "direction_template": "{{ 'forward' }}", + **DIRECTION_ACTION, } }, } @@ -335,11 +497,12 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: "fans": { "test_fan": { "value_template": "{{ 'abc' }}", - "oscillating_template": "{{ 'xyz' }}", - "direction_template": "{{ 'right' }}", "percentage_template": "{{ 0 }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating_template": "{{ 'xyz' }}", + **OSCILLATE_ACTION, + "direction_template": "{{ 'right' }}", + **DIRECTION_ACTION, } }, } @@ -541,77 +704,18 @@ async def test_increase_decrease_speed( _verify(hass, state, value, None, None, None) -async def test_no_value_template(hass: HomeAssistant, calls: list[ServiceCall]) -> None: +async def test_optimistic_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test a fan without a value_template.""" await _register_fan_sources(hass) with assert_setup_component(1, "fan"): test_fan_config = { - "preset_mode_template": "{{ states('input_select.preset_mode') }}", + **OPTIMISTIC_ON_OFF_CONFIG, "preset_modes": ["auto"], - "percentage_template": "{{ states('input_number.percentage') }}", - "oscillating_template": "{{ states('input_select.osc') }}", - "direction_template": "{{ states('input_select.direction') }}", - "turn_on": [ - { - "service": "input_boolean.turn_on", - "entity_id": _STATE_INPUT_BOOLEAN, - }, - { - "service": "test.automation", - "data_template": { - "action": "turn_on", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "turn_off": [ - { - "service": "input_boolean.turn_off", - "entity_id": _STATE_INPUT_BOOLEAN, - }, - { - "service": "test.automation", - "data_template": { - "action": "turn_off", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "set_preset_mode": [ - { - "service": "input_select.select_option", - "data_template": { - "entity_id": _PRESET_MODE_INPUT_SELECT, - "option": "{{ preset_mode }}", - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "set_preset_mode", - "caller": "{{ this.entity_id }}", - "option": "{{ preset_mode }}", - }, - }, - ], - "set_percentage": [ - { - "service": "input_number.set_value", - "data_template": { - "entity_id": _PERCENTAGE_INPUT_NUMBER, - "value": "{{ percentage }}", - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "set_value", - "caller": "{{ this.entity_id }}", - "value": "{{ percentage }}", - }, - }, - ], + **PRESET_MODE_ACTION, + **PERCENTAGE_ACTION, + **OSCILLATE_ACTION, + **DIRECTION_ACTION, } assert await setup.async_setup_component( hass, @@ -624,32 +728,127 @@ async def test_no_value_template(hass: HomeAssistant, calls: list[ServiceCall]) await hass.async_block_till_done() await common.async_turn_on(hass, _TEST_FAN) - _verify(hass, STATE_ON, 0, None, None, "auto") + _verify(hass, STATE_ON) + + assert len(calls) == 1 + assert calls[-1].data["action"] == "turn_on" + assert calls[-1].data["caller"] == _TEST_FAN await common.async_turn_off(hass, _TEST_FAN) - _verify(hass, STATE_OFF, 0, None, None, "auto") + _verify(hass, STATE_OFF) + + assert len(calls) == 2 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == _TEST_FAN percent = 100 await common.async_set_percentage(hass, _TEST_FAN, percent) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == percent - _verify(hass, STATE_ON, percent, None, None, "auto") + _verify(hass, STATE_ON, percent) + + assert len(calls) == 3 + assert calls[-1].data["action"] == "set_percentage" + assert calls[-1].data["percentage"] == 100 + assert calls[-1].data["caller"] == _TEST_FAN await common.async_turn_off(hass, _TEST_FAN) - _verify(hass, STATE_OFF, percent, None, None, "auto") + _verify(hass, STATE_OFF, percent) + + assert len(calls) == 4 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == _TEST_FAN preset = "auto" await common.async_set_preset_mode(hass, _TEST_FAN, preset) assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == preset _verify(hass, STATE_ON, percent, None, None, preset) + assert len(calls) == 5 + assert calls[-1].data["action"] == "set_preset_mode" + assert calls[-1].data["preset_mode"] == preset + assert calls[-1].data["caller"] == _TEST_FAN + await common.async_turn_off(hass, _TEST_FAN) _verify(hass, STATE_OFF, percent, None, None, preset) - await common.async_set_direction(hass, _TEST_FAN, True) - _verify(hass, STATE_OFF, percent, None, None, preset) + assert len(calls) == 6 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == _TEST_FAN + + await common.async_set_direction(hass, _TEST_FAN, DIRECTION_FORWARD) + _verify(hass, STATE_OFF, percent, None, DIRECTION_FORWARD, preset) + + assert len(calls) == 7 + assert calls[-1].data["action"] == "set_direction" + assert calls[-1].data["direction"] == DIRECTION_FORWARD + assert calls[-1].data["caller"] == _TEST_FAN await common.async_oscillate(hass, _TEST_FAN, True) - _verify(hass, STATE_OFF, percent, None, None, preset) + _verify(hass, STATE_OFF, percent, True, DIRECTION_FORWARD, preset) + + assert len(calls) == 8 + assert calls[-1].data["action"] == "set_oscillating" + assert calls[-1].data["oscillating"] is True + assert calls[-1].data["caller"] == _TEST_FAN + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize("style", [ConfigurationStyle.LEGACY]) +@pytest.mark.parametrize( + ("extra_config", "attribute", "action", "verify_attr", "coro", "value"), + [ + ( + OPTIMISTIC_PERCENTAGE_CONFIG, + "percentage", + "set_percentage", + "expected_percentage", + common.async_set_percentage, + 50, + ), + ( + OPTIMISTIC_PRESET_MODE_CONFIG2, + "preset_mode", + "set_preset_mode", + "expected_preset_mode", + common.async_set_preset_mode, + "auto", + ), + ( + OPTIMISTIC_OSCILLATE_CONFIG, + "oscillating", + "set_oscillating", + "expected_oscillating", + common.async_oscillate, + True, + ), + ( + OPTIMISTIC_DIRECTION_CONFIG, + "direction", + "set_direction", + "expected_direction", + common.async_set_direction, + DIRECTION_FORWARD, + ), + ], +) +async def test_optimistic_attributes( + hass: HomeAssistant, + attribute: str, + action: str, + verify_attr: str, + coro, + value: Any, + setup_optimistic_fan_attribute, + calls: list[ServiceCall], +) -> None: + """Test setting percentage with optimistic template.""" + + await coro(hass, _TEST_FAN, value) + _verify(hass, STATE_ON, **{verify_attr: value}) + + assert len(calls) == 1 + assert calls[-1].data["action"] == action + assert calls[-1].data[attribute] == value + assert calls[-1].data["caller"] == _TEST_FAN async def test_increase_decrease_speed_default_speed_count( @@ -702,10 +901,10 @@ async def test_set_invalid_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> def _verify( hass: HomeAssistant, expected_state: str, - expected_percentage: int | None, - expected_oscillating: bool | None, - expected_direction: str | None, - expected_preset_mode: str | None, + expected_percentage: int | None = None, + expected_oscillating: bool | None = None, + expected_direction: str | None = None, + expected_preset_mode: str | None = None, ) -> None: """Verify fan's state, speed and osc.""" state = hass.states.get(_TEST_FAN) @@ -1093,3 +1292,57 @@ async def test_implemented_preset_mode(hass: HomeAssistant) -> None: attributes = state.attributes assert attributes.get("percentage") is None assert attributes.get("supported_features") & FanEntityFeature.PRESET_MODE + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "turn_on": [], + "turn_off": [], + }, + ), + ], +) +@pytest.mark.parametrize( + ("extra_config", "supported_features"), + [ + ( + { + "set_percentage": [], + }, + FanEntityFeature.SET_SPEED, + ), + ( + { + "set_preset_mode": [], + }, + FanEntityFeature.PRESET_MODE, + ), + ( + { + "set_oscillating": [], + }, + FanEntityFeature.OSCILLATE, + ), + ( + { + "set_direction": [], + }, + FanEntityFeature.DIRECTION, + ), + ], +) +async def test_empty_action_config( + hass: HomeAssistant, + supported_features: FanEntityFeature, + setup_test_fan_with_extra_config, +) -> None: + """Test configuration with empty script.""" + state = hass.states.get(_TEST_FAN) + assert state.attributes["supported_features"] == ( + FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON | supported_features + ) diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index c0aade84e0f..f240c2412e0 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -25,6 +25,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er @@ -159,6 +160,20 @@ OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG = { } +TEST_STATE_TRIGGER = { + "trigger": {"trigger": "state", "entity_id": "light.test_state"}, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [{"event": "action_event", "event_data": {"what": "triggering_entity"}}], +} + + +TEST_EVENT_TRIGGER = { + "trigger": {"platform": "event", "event_type": "test_event"}, + "variables": {"type": "{{ trigger.event.data.type }}"}, + "action": [{"event": "action_event", "event_data": {"type": "{{ type }}"}}], +} + + TEST_MISSING_KEY_CONFIG = { "turn_on": { "service": "light.turn_on", @@ -434,7 +449,7 @@ async def async_setup_legacy_format_with_attribute( ) -async def async_setup_new_format( +async def async_setup_modern_format( hass: HomeAssistant, count: int, light_config: dict[str, Any] ) -> None: """Do setup of light integration via new format.""" @@ -461,7 +476,51 @@ async def async_setup_modern_format_with_attribute( ) -> None: """Do setup of a legacy light that has a single templated attribute.""" extra = {attribute: attribute_template} if attribute and attribute_template else {} - await async_setup_new_format( + await async_setup_modern_format( + hass, + count, + { + "name": "test_template_light", + **extra_config, + "state": "{{ 1 == 1 }}", + **extra, + }, + ) + + +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, light_config: dict[str, Any] +) -> None: + """Do setup of light integration via new format.""" + config = { + "template": { + **TEST_STATE_TRIGGER, + "light": light_config, + } + } + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_trigger_format_with_attribute( + hass: HomeAssistant, + count: int, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of a legacy light that has a single templated attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + await async_setup_trigger_format( hass, count, { @@ -484,7 +543,9 @@ async def setup_light( if style == ConfigurationStyle.LEGACY: await async_setup_legacy_format(hass, count, light_config) elif style == ConfigurationStyle.MODERN: - await async_setup_new_format(hass, count, light_config) + await async_setup_modern_format(hass, count, light_config) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, light_config) @pytest.fixture @@ -507,7 +568,17 @@ async def setup_state_light( }, ) elif style == ConfigurationStyle.MODERN: - await async_setup_new_format( + await async_setup_modern_format( + hass, + count, + { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "name": "test_template_light", + "state": state_template, + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( hass, count, { @@ -536,6 +607,10 @@ async def setup_single_attribute_light( await async_setup_modern_format_with_attribute( hass, count, attribute, attribute_template, extra_config ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format_with_attribute( + hass, count, attribute, attribute_template, extra_config + ) @pytest.fixture @@ -554,6 +629,10 @@ async def setup_single_action_light( await async_setup_modern_format_with_attribute( hass, count, "", "", extra_config ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format_with_attribute( + hass, count, "", "", extra_config + ) @pytest.fixture @@ -579,7 +658,7 @@ async def setup_empty_action_light( }, ) elif style == ConfigurationStyle.MODERN: - await async_setup_new_format( + await async_setup_modern_format( hass, count, { @@ -627,7 +706,20 @@ async def setup_light_with_effects( }, ) elif style == ConfigurationStyle.MODERN: - await async_setup_new_format( + await async_setup_modern_format( + hass, + count, + { + "name": "test_template_light", + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "state": "{{true}}", + **common, + "effect_list": effect_list_template, + "effect": effect_template, + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( hass, count, { @@ -674,7 +766,19 @@ async def setup_light_with_mireds( }, ) elif style == ConfigurationStyle.MODERN: - await async_setup_new_format( + await async_setup_modern_format( + hass, + count, + { + "name": "test_template_light", + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "state": "{{ 1 == 1 }}", + **common, + "temperature": "{{200}}", + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( hass, count, { @@ -720,7 +824,21 @@ async def setup_light_with_transition_template( }, ) elif style == ConfigurationStyle.MODERN: - await async_setup_new_format( + await async_setup_modern_format( + hass, + count, + { + "name": "test_template_light", + **OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG, + "state": "{{ 1 == 1 }}", + **common, + "effect_list": "{{ ['Disco', 'Police'] }}", + "effect": "{{ None }}", + "supports_transition": transition_template, + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( hass, count, { @@ -741,19 +859,24 @@ async def setup_light_with_transition_template( [(0, [ColorMode.BRIGHTNESS])], ) @pytest.mark.parametrize( - "style", + ("style", "expected_state"), [ - ConfigurationStyle.LEGACY, - ConfigurationStyle.MODERN, + (ConfigurationStyle.LEGACY, STATE_OFF), + (ConfigurationStyle.MODERN, STATE_OFF), + (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), ], ) @pytest.mark.parametrize("state_template", ["{{states.test['big.fat...']}}"]) async def test_template_state_invalid( - hass: HomeAssistant, supported_features, supported_color_modes, setup_state_light + hass: HomeAssistant, + supported_features, + supported_color_modes, + expected_state, + setup_state_light, ) -> None: """Test template state with render error.""" state = hass.states.get("light.test_template_light") - assert state.state == STATE_OFF + assert state.state == expected_state assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == supported_color_modes assert state.attributes["supported_features"] == supported_features @@ -765,6 +888,7 @@ async def test_template_state_invalid( [ ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) @pytest.mark.parametrize("state_template", ["{{ states.light.test_state.state }}"]) @@ -795,6 +919,7 @@ async def test_template_state_text(hass: HomeAssistant, setup_state_light) -> No [ ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) @pytest.mark.parametrize( @@ -812,13 +937,18 @@ async def test_template_state_text(hass: HomeAssistant, setup_state_light) -> No ), ], ) -async def test_legacy_template_state_boolean( +async def test_template_state_boolean( hass: HomeAssistant, expected_color_mode, expected_state, + style, setup_state_light, ) -> None: """Test the setting of the state with boolean on.""" + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", expected_state) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state.state == expected_state assert state.attributes.get("color_mode") == expected_color_mode @@ -860,6 +990,14 @@ async def test_legacy_template_state_boolean( }, ConfigurationStyle.MODERN, ), + ( + { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "name": "test_template_light", + "state": "{%- if false -%}", + }, + ConfigurationStyle.TRIGGER, + ), ], ) async def test_template_config_errors(hass: HomeAssistant, setup_light) -> None: @@ -880,6 +1018,11 @@ async def test_template_config_errors(hass: HomeAssistant, setup_light) -> None: ConfigurationStyle.MODERN, 0, ), + ( + {"name": "light_one", "state": "{{ 1== 1}}", **TEST_MISSING_KEY_CONFIG}, + ConfigurationStyle.TRIGGER, + 0, + ), ], ) async def test_missing_key(hass: HomeAssistant, count, setup_light) -> None: @@ -896,6 +1039,7 @@ async def test_missing_key(hass: HomeAssistant, count, setup_light) -> None: [ ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) @pytest.mark.parametrize("state_template", ["{{ states.light.test_state.state }}"]) @@ -946,11 +1090,21 @@ async def test_on_action( ( { "name": "test_template_light", + "state": "{{states.light.test_state.state}}", **TEST_ON_ACTION_WITH_TRANSITION_CONFIG, "supports_transition": "{{true}}", }, ConfigurationStyle.MODERN, ), + ( + { + "name": "test_template_light", + "state": "{{states.light.test_state.state}}", + **TEST_ON_ACTION_WITH_TRANSITION_CONFIG, + "supports_transition": "{{true}}", + }, + ConfigurationStyle.TRIGGER, + ), ], ) async def test_on_action_with_transition( @@ -984,7 +1138,7 @@ async def test_on_action_with_transition( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("light_config", "style"), + ("light_config", "style", "initial_state"), [ ( { @@ -993,6 +1147,7 @@ async def test_on_action_with_transition( } }, ConfigurationStyle.LEGACY, + STATE_OFF, ), ( { @@ -1000,11 +1155,21 @@ async def test_on_action_with_transition( **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, }, ConfigurationStyle.MODERN, + STATE_OFF, + ), + ( + { + "name": "test_template_light", + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + }, + ConfigurationStyle.TRIGGER, + STATE_UNKNOWN, ), ], ) async def test_on_action_optimistic( hass: HomeAssistant, + initial_state: str, setup_light, calls: list[ServiceCall], ) -> None: @@ -1013,7 +1178,7 @@ async def test_on_action_optimistic( await hass.async_block_till_done() state = hass.states.get("light.test_template_light") - assert state.state == STATE_OFF + assert state.state == initial_state assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 @@ -1058,6 +1223,7 @@ async def test_on_action_optimistic( [ ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) @pytest.mark.parametrize("state_template", ["{{ states.light.test_state.state }}"]) @@ -1113,6 +1279,15 @@ async def test_off_action( }, ConfigurationStyle.MODERN, ), + ( + { + "name": "test_template_light", + "state": "{{states.light.test_state.state}}", + **TEST_OFF_ACTION_WITH_TRANSITION_CONFIG, + "supports_transition": "{{true}}", + }, + ConfigurationStyle.TRIGGER, + ), ], ) async def test_off_action_with_transition( @@ -1145,7 +1320,7 @@ async def test_off_action_with_transition( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("light_config", "style"), + ("light_config", "style", "initial_state"), [ ( { @@ -1154,6 +1329,7 @@ async def test_off_action_with_transition( } }, ConfigurationStyle.LEGACY, + STATE_OFF, ), ( { @@ -1161,15 +1337,24 @@ async def test_off_action_with_transition( **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, }, ConfigurationStyle.MODERN, + STATE_OFF, + ), + ( + { + "name": "test_template_light", + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + }, + ConfigurationStyle.TRIGGER, + STATE_UNKNOWN, ), ], ) async def test_off_action_optimistic( - hass: HomeAssistant, setup_light, calls: list[ServiceCall] + hass: HomeAssistant, initial_state, setup_light, calls: list[ServiceCall] ) -> None: """Test off action with optimistic state.""" state = hass.states.get("light.test_template_light") - assert state.state == STATE_OFF + assert state.state == initial_state assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 @@ -1195,6 +1380,7 @@ async def test_off_action_optimistic( [ ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) @pytest.mark.parametrize("state_template", ["{{1 == 1}}"]) @@ -1235,6 +1421,7 @@ async def test_level_action_no_template( [ (ConfigurationStyle.LEGACY, "level_template"), (ConfigurationStyle.MODERN, "level"), + (ConfigurationStyle.TRIGGER, "level"), ], ) @pytest.mark.parametrize( @@ -1255,14 +1442,20 @@ async def test_level_action_no_template( ) async def test_level_template( hass: HomeAssistant, + style: ConfigurationStyle, expected_level: Any, expected_color_mode: ColorMode, setup_single_attribute_light, ) -> None: """Test the template for the level.""" + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state.attributes.get("brightness") == expected_level assert state.state == STATE_ON + assert state.attributes["color_mode"] == expected_color_mode assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 @@ -1276,6 +1469,7 @@ async def test_level_template( [ (ConfigurationStyle.LEGACY, "temperature_template"), (ConfigurationStyle.MODERN, "temperature"), + (ConfigurationStyle.TRIGGER, "temperature"), ], ) @pytest.mark.parametrize( @@ -1292,15 +1486,20 @@ async def test_level_template( ) async def test_temperature_template( hass: HomeAssistant, + style: ConfigurationStyle, expected_temp: Any, expected_color_mode: ColorMode, setup_single_attribute_light, ) -> None: """Test the template for the temperature.""" + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state.attributes.get("color_temp") == expected_temp assert state.state == STATE_ON - assert state.attributes["color_mode"] == expected_color_mode + assert state.attributes.get("color_mode") == expected_color_mode assert state.attributes["supported_color_modes"] == [ColorMode.COLOR_TEMP] assert state.attributes["supported_features"] == 0 @@ -1313,6 +1512,7 @@ async def test_temperature_template( [ ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) async def test_temperature_action_no_template( @@ -1369,6 +1569,15 @@ async def test_temperature_action_no_template( ConfigurationStyle.MODERN, "light.template_light", ), + ( + { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "name": "Template light", + "state": "{{ 1 == 1 }}", + }, + ConfigurationStyle.TRIGGER, + "light.template_light", + ), ], ) async def test_friendly_name(hass: HomeAssistant, entity_id: str, setup_light) -> None: @@ -1388,6 +1597,7 @@ async def test_friendly_name(hass: HomeAssistant, entity_id: str, setup_light) - [ (ConfigurationStyle.LEGACY, "icon_template"), (ConfigurationStyle.MODERN, "icon"), + (ConfigurationStyle.TRIGGER, "icon"), ], ) @pytest.mark.parametrize( @@ -1396,7 +1606,7 @@ async def test_friendly_name(hass: HomeAssistant, entity_id: str, setup_light) - async def test_icon_template(hass: HomeAssistant, setup_single_attribute_light) -> None: """Test icon template.""" state = hass.states.get("light.test_template_light") - assert state.attributes.get("icon") == "" + assert state.attributes.get("icon") in ("", None) state = hass.states.async_set("light.test_state", STATE_ON) await hass.async_block_till_done() @@ -1414,6 +1624,7 @@ async def test_icon_template(hass: HomeAssistant, setup_single_attribute_light) [ (ConfigurationStyle.LEGACY, "entity_picture_template"), (ConfigurationStyle.MODERN, "picture"), + (ConfigurationStyle.TRIGGER, "picture"), ], ) @pytest.mark.parametrize( @@ -1425,7 +1636,7 @@ async def test_entity_picture_template( ) -> None: """Test entity_picture template.""" state = hass.states.get("light.test_template_light") - assert state.attributes.get("entity_picture") == "" + assert state.attributes.get("entity_picture") in ("", None) state = hass.states.async_set("light.test_state", STATE_ON) await hass.async_block_till_done() @@ -1488,6 +1699,7 @@ async def test_legacy_color_action_no_template( [ ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) async def test_hs_color_action_no_template( @@ -1529,6 +1741,7 @@ async def test_hs_color_action_no_template( [ ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) async def test_rgb_color_action_no_template( @@ -1571,6 +1784,7 @@ async def test_rgb_color_action_no_template( [ ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) async def test_rgbw_color_action_no_template( @@ -1617,6 +1831,7 @@ async def test_rgbw_color_action_no_template( [ ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) async def test_rgbww_color_action_no_template( @@ -1702,6 +1917,7 @@ async def test_legacy_color_template( [ (ConfigurationStyle.LEGACY, "hs_template"), (ConfigurationStyle.MODERN, "hs"), + (ConfigurationStyle.TRIGGER, "hs"), ], ) @pytest.mark.parametrize( @@ -1723,9 +1939,14 @@ async def test_hs_template( hass: HomeAssistant, expected_hs, expected_color_mode, + style: ConfigurationStyle, setup_single_attribute_light, ) -> None: """Test the template for the color.""" + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") == expected_hs assert state.state == STATE_ON @@ -1742,6 +1963,7 @@ async def test_hs_template( [ (ConfigurationStyle.LEGACY, "rgb_template"), (ConfigurationStyle.MODERN, "rgb"), + (ConfigurationStyle.TRIGGER, "rgb"), ], ) @pytest.mark.parametrize( @@ -1764,9 +1986,14 @@ async def test_rgb_template( hass: HomeAssistant, expected_rgb, expected_color_mode, + style: ConfigurationStyle, setup_single_attribute_light, ) -> None: """Test the template for the color.""" + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state.attributes.get("rgb_color") == expected_rgb assert state.state == STATE_ON @@ -1783,6 +2010,7 @@ async def test_rgb_template( [ (ConfigurationStyle.LEGACY, "rgbw_template"), (ConfigurationStyle.MODERN, "rgbw"), + (ConfigurationStyle.TRIGGER, "rgbw"), ], ) @pytest.mark.parametrize( @@ -1806,9 +2034,14 @@ async def test_rgbw_template( hass: HomeAssistant, expected_rgbw, expected_color_mode, + style: ConfigurationStyle, setup_single_attribute_light, ) -> None: """Test the template for the color.""" + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state.attributes.get("rgbw_color") == expected_rgbw assert state.state == STATE_ON @@ -1825,6 +2058,7 @@ async def test_rgbw_template( [ (ConfigurationStyle.LEGACY, "rgbww_template"), (ConfigurationStyle.MODERN, "rgbww"), + (ConfigurationStyle.TRIGGER, "rgbww"), ], ) @pytest.mark.parametrize( @@ -1853,9 +2087,14 @@ async def test_rgbww_template( hass: HomeAssistant, expected_rgbww, expected_color_mode, + style: ConfigurationStyle, setup_single_attribute_light, ) -> None: """Test the template for the color.""" + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state.attributes.get("rgbww_color") == expected_rgbww assert state.state == STATE_ON @@ -1887,6 +2126,15 @@ async def test_rgbww_template( }, ConfigurationStyle.MODERN, ), + ( + { + "name": "test_template_light", + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "state": "{{1 == 1}}", + **TEST_ALL_COLORS_NO_TEMPLATE_CONFIG, + }, + ConfigurationStyle.TRIGGER, + ), ], ) async def test_all_colors_mode_no_template( @@ -2084,7 +2332,8 @@ async def test_all_colors_mode_no_template( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("effect_list_template", "effect_template", "effect", "expected"), @@ -2097,10 +2346,17 @@ async def test_effect_action( hass: HomeAssistant, effect: str, expected: Any, + style: ConfigurationStyle, setup_light_with_effects, calls: list[ServiceCall], ) -> None: """Test setting valid effect with template.""" + + if style == ConfigurationStyle.TRIGGER: + # Ensures the trigger template entity updates + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state is not None @@ -2123,7 +2379,8 @@ async def test_effect_action( @pytest.mark.parametrize(("count", "effect_template"), [(1, "{{ None }}")]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("expected_effect_list", "effect_list_template"), @@ -2145,9 +2402,16 @@ async def test_effect_action( ], ) async def test_effect_list_template( - hass: HomeAssistant, expected_effect_list, setup_light_with_effects + hass: HomeAssistant, + expected_effect_list, + style: ConfigurationStyle, + setup_light_with_effects, ) -> None: """Test the template for the effect list.""" + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("effect_list") == expected_effect_list @@ -2158,7 +2422,8 @@ async def test_effect_list_template( [(1, "{{ ['Strobe color', 'Police', 'Christmas', 'RGB', 'Random Loop'] }}")], ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("expected_effect", "effect_template"), @@ -2171,9 +2436,16 @@ async def test_effect_list_template( ], ) async def test_effect_template( - hass: HomeAssistant, expected_effect, setup_light_with_effects + hass: HomeAssistant, + expected_effect, + style: ConfigurationStyle, + setup_light_with_effects, ) -> None: """Test the template for the effect.""" + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("effect") == expected_effect @@ -2185,6 +2457,7 @@ async def test_effect_template( [ (ConfigurationStyle.LEGACY, "min_mireds_template"), (ConfigurationStyle.MODERN, "min_mireds"), + (ConfigurationStyle.TRIGGER, "min_mireds"), ], ) @pytest.mark.parametrize( @@ -2199,9 +2472,16 @@ async def test_effect_template( ], ) async def test_min_mireds_template( - hass: HomeAssistant, expected_min_mireds, setup_light_with_mireds + hass: HomeAssistant, + expected_min_mireds, + style: ConfigurationStyle, + setup_light_with_mireds, ) -> None: """Test the template for the min mireds.""" + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("min_mireds") == expected_min_mireds @@ -2213,6 +2493,7 @@ async def test_min_mireds_template( [ (ConfigurationStyle.LEGACY, "max_mireds_template"), (ConfigurationStyle.MODERN, "max_mireds"), + (ConfigurationStyle.TRIGGER, "max_mireds"), ], ) @pytest.mark.parametrize( @@ -2227,9 +2508,16 @@ async def test_min_mireds_template( ], ) async def test_max_mireds_template( - hass: HomeAssistant, expected_max_mireds, setup_light_with_mireds + hass: HomeAssistant, + expected_max_mireds, + style: ConfigurationStyle, + setup_light_with_mireds, ) -> None: """Test the template for the max mireds.""" + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("max_mireds") == expected_max_mireds @@ -2243,6 +2531,7 @@ async def test_max_mireds_template( [ (ConfigurationStyle.LEGACY, "supports_transition_template"), (ConfigurationStyle.MODERN, "supports_transition"), + (ConfigurationStyle.TRIGGER, "supports_transition"), ], ) @pytest.mark.parametrize( @@ -2257,9 +2546,17 @@ async def test_max_mireds_template( ], ) async def test_supports_transition_template( - hass: HomeAssistant, expected_supports_transition, setup_single_attribute_light + hass: HomeAssistant, + style: ConfigurationStyle, + expected_supports_transition, + setup_single_attribute_light, ) -> None: """Test the template for the supports transition.""" + if style == ConfigurationStyle.TRIGGER: + # Ensures the trigger template entity updates + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") expected_value = 1 @@ -2277,10 +2574,11 @@ async def test_supports_transition_template( ("count", "transition_template"), [(1, "{{ states('sensor.test') }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) async def test_supports_transition_template_updates( - hass: HomeAssistant, setup_light_with_transition_template + hass: HomeAssistant, style: ConfigurationStyle, setup_light_with_transition_template ) -> None: """Test the template for the supports transition dynamically.""" state = hass.states.get("light.test_template_light") @@ -2288,12 +2586,24 @@ async def test_supports_transition_template_updates( hass.states.async_set("sensor.test", 0) await hass.async_block_till_done() + + if style == ConfigurationStyle.TRIGGER: + # Ensures the trigger template entity updates + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") supported_features = state.attributes.get("supported_features") assert supported_features == LightEntityFeature.EFFECT hass.states.async_set("sensor.test", 1) await hass.async_block_till_done() + + if style == ConfigurationStyle.TRIGGER: + # Ensures the trigger template entity updates + hass.states.async_set("light.test_state", STATE_OFF) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") supported_features = state.attributes.get("supported_features") assert ( @@ -2302,6 +2612,12 @@ async def test_supports_transition_template_updates( hass.states.async_set("sensor.test", 0) await hass.async_block_till_done() + + if style == ConfigurationStyle.TRIGGER: + # Ensures the trigger template entity updates + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") supported_features = state.attributes.get("supported_features") assert supported_features == LightEntityFeature.EFFECT @@ -2322,16 +2638,22 @@ async def test_supports_transition_template_updates( [ (ConfigurationStyle.LEGACY, "availability_template"), (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), ], ) async def test_available_template_with_entities( - hass: HomeAssistant, setup_single_attribute_light + hass: HomeAssistant, style: ConfigurationStyle, setup_single_attribute_light ) -> None: """Test availability templates with values from other entities.""" # When template returns true.. hass.states.async_set(_STATE_AVAILABILITY_BOOLEAN, STATE_ON) await hass.async_block_till_done() + if style == ConfigurationStyle.TRIGGER: + # Ensures the trigger template entity updates + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + # Device State should not be unavailable assert hass.states.get("light.test_template_light").state != STATE_UNAVAILABLE @@ -2339,6 +2661,11 @@ async def test_available_template_with_entities( hass.states.async_set(_STATE_AVAILABILITY_BOOLEAN, STATE_OFF) await hass.async_block_till_done() + if style == ConfigurationStyle.TRIGGER: + # Ensures the trigger template entity updates + hass.states.async_set("light.test_state", STATE_OFF) + await hass.async_block_till_done() + # device state should be unavailable assert hass.states.get("light.test_template_light").state == STATE_UNAVAILABLE @@ -2361,7 +2688,9 @@ async def test_available_template_with_entities( ], ) async def test_invalid_availability_template_keeps_component_available( - hass: HomeAssistant, setup_single_attribute_light, caplog_setup_text + hass: HomeAssistant, + setup_single_attribute_light, + caplog_setup_text, ) -> None: """Test that an invalid availability keeps the device available.""" assert hass.states.get("light.test_template_light").state != STATE_UNAVAILABLE @@ -2392,6 +2721,19 @@ async def test_invalid_availability_template_keeps_component_available( ], ConfigurationStyle.MODERN, ), + ( + [ + { + "name": "test_template_light_01", + **TEST_UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_light_02", + **TEST_UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.TRIGGER, + ), ], ) async def test_unique_id(hass: HomeAssistant, setup_light) -> None: diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 6f0e6be8a2a..56eaa120b20 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1527,6 +1527,217 @@ async def test_trigger_entity_available(hass: HomeAssistant) -> None: assert state.state == "unavailable" +async def test_trigger_entity_available_skips_state( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test trigger entity availability works.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Never Available", + "availability": "{{ trigger and trigger.event.data.beer == 2 }}", + "state": "{{ noexist - 1 }}", + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + # Sensors are unknown if never triggered + state = hass.states.get("sensor.never_available") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 1}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.never_available") + assert state.state == "unavailable" + + assert "'noexist' is undefined" not in caplog.text + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.never_available") + assert state.state == "unavailable" + + assert "'noexist' is undefined" in caplog.text + + +async def test_trigger_state_with_availability_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test trigger entity is available when attributes have syntax errors.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Test Sensor", + "availability": "{{ what_the_heck == 2 }}", + "state": "{{ trigger.event.data.beer }}", + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + # Sensors are unknown if never triggered + state = hass.states.get("sensor.test_sensor") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sensor") + assert state.state == "2" + + assert ( + "Error rendering availability template for sensor.test_sensor: UndefinedError: 'what_the_heck' is undefined" + in caplog.text + ) + + +async def test_trigger_available_with_attribute_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test trigger entity is available when attributes have syntax errors.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Test Sensor", + "availability": "{{ trigger and trigger.event.data.beer == 2 }}", + "state": "{{ trigger.event.data.beer }}", + "attributes": { + "beer": "{{ trigger.event.data.beer }}", + "no_beer": "{{ sad - 1 }}", + "more_beer": "{{ beer + 1 }}", + }, + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + # Sensors are unknown if never triggered + state = hass.states.get("sensor.test_sensor") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sensor") + assert state.state == "2" + + assert state.attributes["beer"] == 2 + assert "no_beer" not in state.attributes + assert ( + "Error rendering attributes.no_beer template for sensor.test_sensor: UndefinedError: 'sad' is undefined" + in caplog.text + ) + assert state.attributes["more_beer"] == 3 + + +async def test_trigger_attribute_order( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test trigger entity attributes order.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Test Sensor", + "availability": "{{ trigger and trigger.event.data.beer == 2 }}", + "state": "{{ trigger.event.data.beer }}", + "attributes": { + "beer": "{{ trigger.event.data.beer }}", + "no_beer": "{{ sad - 1 }}", + "more_beer": "{{ beer + 1 }}", + "all_the_beer": "{{ this.state | int + more_beer }}", + }, + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + # Sensors are unknown if never triggered + state = hass.states.get("sensor.test_sensor") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sensor") + assert state.state == "2" + + assert state.attributes["beer"] == 2 + assert "no_beer" not in state.attributes + assert ( + "Error rendering attributes.no_beer template for sensor.test_sensor: UndefinedError: 'sad' is undefined" + in caplog.text + ) + assert state.attributes["more_beer"] == 3 + assert ( + "Error rendering attributes.all_the_beer template for sensor.test_sensor: ValueError: Template error: int got invalid input 'unknown' when rendering template '{{ this.state | int + more_beer }}' but no default was specified" + in caplog.text + ) + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sensor") + assert state.state == "2" + + assert state.attributes["beer"] == 2 + assert state.attributes["more_beer"] == 3 + assert state.attributes["all_the_beer"] == 5 + + assert ( + caplog.text.count( + "Error rendering attributes.no_beer template for sensor.test_sensor: UndefinedError: 'sad' is undefined" + ) + == 2 + ) + + async def test_trigger_entity_device_class_parsing_works(hass: HomeAssistant) -> None: """Test trigger entity device class parsing works.""" assert await async_setup_component( @@ -2092,6 +2303,61 @@ async def test_trigger_conditional_action(hass: HomeAssistant) -> None: assert len(events) == 1 +@pytest.mark.parametrize("trigger_field", ["trigger", "triggers"]) +@pytest.mark.parametrize("condition_field", ["condition", "conditions"]) +@pytest.mark.parametrize("action_field", ["action", "actions"]) +async def test_legacy_and_new_config_schema( + hass: HomeAssistant, trigger_field: str, condition_field: str, action_field: str +) -> None: + """Tests that both old and new config schema (singular -> plural) work.""" + + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "unique_id": "listening-test-event", + f"{trigger_field}": { + "platform": "event", + "event_type": "beer_event", + }, + f"{condition_field}": [ + { + "condition": "template", + "value_template": "{{ trigger.event.data.beer >= 42 }}", + } + ], + f"{action_field}": [ + {"event": "test_event_by_action"}, + ], + "sensor": [ + { + "name": "Unimportant", + "state": "Uninteresting", + } + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + event = "test_event_by_action" + events = async_capture_events(hass, event) + + hass.bus.async_fire("beer_event", {"beer": 1}) + await hass.async_block_till_done() + + assert len(events) == 0 + + hass.bus.async_fire("beer_event", {"beer": 42}) + await hass.async_block_till_done() + + assert len(events) == 1 + + async def test_device_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index 43db93ac146..de6894c73a8 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -37,6 +37,12 @@ TEST_OBJECT_ID = "test_template_switch" TEST_ENTITY_ID = f"switch.{TEST_OBJECT_ID}" TEST_STATE_ENTITY_ID = "switch.test_state" +TEST_EVENT_TRIGGER = { + "trigger": {"platform": "event", "event_type": "test_event"}, + "variables": {"type": "{{ trigger.event.data.type }}"}, + "action": [{"event": "action_event", "event_data": {"type": "{{ type }}"}}], +} + SWITCH_TURN_ON = { "service": "test.automation", "data_template": { @@ -100,6 +106,33 @@ async def async_setup_modern_format( await hass.async_block_till_done() +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, switch_config: dict[str, Any] +) -> None: + """Do setup of switch integration via modern format.""" + config = {"template": {**TEST_EVENT_TRIGGER, "switch": switch_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_ensure_triggered_entity_updates( + hass: HomeAssistant, style: ConfigurationStyle, **kwargs +) -> None: + """Trigger template entities.""" + if style == ConfigurationStyle.TRIGGER: + hass.bus.async_fire("test_event", {"type": "test_event", **kwargs}) + await hass.async_block_till_done() + + @pytest.fixture async def setup_switch( hass: HomeAssistant, @@ -112,6 +145,8 @@ async def setup_switch( await async_setup_legacy_format(hass, count, switch_config) elif style == ConfigurationStyle.MODERN: await async_setup_modern_format(hass, count, switch_config) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, switch_config) @pytest.fixture @@ -142,6 +177,15 @@ async def setup_state_switch( "state": state_template, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + "state": state_template, + }, + ) @pytest.fixture @@ -176,6 +220,16 @@ async def setup_single_attribute_switch( **extra, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + "state": "{{ 1 == 1 }}", + **extra, + }, + ) @pytest.fixture @@ -203,6 +257,55 @@ async def setup_optimistic_switch( **NAMED_SWITCH_ACTIONS, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + }, + ) + + +@pytest.fixture +async def setup_single_attribute_optimistic_switch( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + attribute: str, + attribute_template: str, +) -> None: + """Do setup of switch integration testing a single attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **SWITCH_ACTIONS, + **extra, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + **extra, + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + **extra, + }, + ) async def test_legacy_to_modern_config(hass: HomeAssistant) -> None: @@ -238,10 +341,14 @@ async def test_legacy_to_modern_config(hass: HomeAssistant) -> None: @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ True }}")]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -async def test_setup(hass: HomeAssistant, setup_state_switch) -> None: +async def test_setup( + hass: HomeAssistant, style: ConfigurationStyle, setup_state_switch +) -> None: """Test template.""" + await async_ensure_triggered_entity_updates(hass, style) state = hass.states.get(TEST_ENTITY_ID) assert state is not None assert state.name == TEST_OBJECT_ID @@ -326,19 +433,26 @@ async def test_flow_preview( ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -async def test_template_state_text(hass: HomeAssistant, setup_state_switch) -> None: +async def test_template_state_text( + hass: HomeAssistant, style: ConfigurationStyle, setup_state_switch +) -> None: """Test the state text of a template.""" hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() + await async_ensure_triggered_entity_updates(hass, style) + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() + await async_ensure_triggered_entity_updates(hass, style) + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF @@ -352,12 +466,14 @@ async def test_template_state_text(hass: HomeAssistant, setup_state_switch) -> N ], ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) async def test_template_state_boolean( - hass: HomeAssistant, expected: str, setup_state_switch + hass: HomeAssistant, expected: str, style: ConfigurationStyle, setup_state_switch ) -> None: """Test the setting of the state with boolean template.""" + await async_ensure_triggered_entity_updates(hass, style) state = hass.states.get(TEST_ENTITY_ID) assert state.state == expected @@ -371,22 +487,107 @@ async def test_template_state_boolean( [ (ConfigurationStyle.LEGACY, "icon_template"), (ConfigurationStyle.MODERN, "icon"), + (ConfigurationStyle.TRIGGER, "icon"), ], ) async def test_icon_template( - hass: HomeAssistant, setup_single_attribute_switch + hass: HomeAssistant, style: ConfigurationStyle, setup_single_attribute_switch ) -> None: """Test the state text of a template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("icon") == "" + assert state.attributes.get("icon") in ("", None) hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() + await async_ensure_triggered_entity_updates(hass, style) + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["icon"] == "mdi:check" +@pytest.mark.parametrize( + ("config_attr", "attribute", "expected"), + [("icon", "icon", "mdi:icon"), ("picture", "entity_picture", "picture.jpg")], +) +async def test_attributes_with_optimistic_state( + hass: HomeAssistant, + config_attr: str, + attribute: str, + expected: str, + calls: list[ServiceCall], +) -> None: + """Test attributes when trigger entity is optimistic.""" + await async_setup_trigger_format( + hass, + 1, + { + **NAMED_SWITCH_ACTIONS, + config_attr: "{{ trigger.event.data.attr }}", + }, + ) + + hass.states.async_set(TEST_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes.get(attribute) is None + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes.get(attribute) is None + + assert len(calls) == 1 + assert calls[-1].data["action"] == "turn_on" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes.get(attribute) is None + + assert len(calls) == 2 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + await async_ensure_triggered_entity_updates( + hass, ConfigurationStyle.TRIGGER, attr=expected + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes.get(attribute) == expected + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes.get(attribute) == expected + + assert len(calls) == 3 + assert calls[-1].data["action"] == "turn_on" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + @pytest.mark.parametrize( ("count", "attribute_template"), [(1, "{% if states.switch.test_state.state %}/local/switch.png{% endif %}")], @@ -396,18 +597,21 @@ async def test_icon_template( [ (ConfigurationStyle.LEGACY, "entity_picture_template"), (ConfigurationStyle.MODERN, "picture"), + (ConfigurationStyle.TRIGGER, "picture"), ], ) async def test_entity_picture_template( - hass: HomeAssistant, setup_single_attribute_switch + hass: HomeAssistant, style: ConfigurationStyle, setup_single_attribute_switch ) -> None: """Test entity_picture template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("entity_picture") == "" + assert state.attributes.get("entity_picture") in ("", None) hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() + await async_ensure_triggered_entity_updates(hass, style) + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["entity_picture"] == "/local/switch.png" @@ -415,7 +619,7 @@ async def test_entity_picture_template( @pytest.mark.parametrize(("count", "state_template"), [(0, "{% if rubbish %}")]) @pytest.mark.parametrize( "style", - [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) async def test_template_syntax_error(hass: HomeAssistant, setup_state_switch) -> None: """Test templating syntax error.""" @@ -613,15 +817,21 @@ async def test_missing_off_does_not_create( ("count", "state_template"), [(1, "{{ states('switch.test_state') }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) async def test_on_action( - hass: HomeAssistant, setup_state_switch, calls: list[ServiceCall] + hass: HomeAssistant, + style: ConfigurationStyle, + setup_state_switch, + calls: list[ServiceCall], ) -> None: """Test on action.""" hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() + await async_ensure_triggered_entity_updates(hass, style) + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF @@ -639,7 +849,8 @@ async def test_on_action( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) async def test_on_action_optimistic( hass: HomeAssistant, setup_optimistic_switch, calls: list[ServiceCall] @@ -670,15 +881,21 @@ async def test_on_action_optimistic( ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) async def test_off_action( - hass: HomeAssistant, setup_state_switch, calls: list[ServiceCall] + hass: HomeAssistant, + style: ConfigurationStyle, + setup_state_switch, + calls: list[ServiceCall], ) -> None: """Test off action.""" hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() + await async_ensure_triggered_entity_updates(hass, style) + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON @@ -696,7 +913,8 @@ async def test_off_action( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) async def test_off_action_optimistic( hass: HomeAssistant, setup_optimistic_switch, calls: list[ServiceCall] @@ -760,6 +978,24 @@ async def test_off_action_optimistic( }, template.DOMAIN, ), + ( + { + "template": { + "trigger": {"trigger": "event", "event_type": "test_event"}, + "switch": [ + { + "name": "s1", + **SWITCH_ACTIONS, + }, + { + "name": "s2", + **SWITCH_ACTIONS, + }, + ], + } + }, + template.DOMAIN, + ), ], ) async def test_restore_state( @@ -800,20 +1036,25 @@ async def test_restore_state( [ (ConfigurationStyle.LEGACY, "availability_template"), (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), ], ) async def test_available_template_with_entities( - hass: HomeAssistant, setup_single_attribute_switch + hass: HomeAssistant, style: ConfigurationStyle, setup_single_attribute_switch ) -> None: """Test availability templates with values from other entities.""" hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() + await async_ensure_triggered_entity_updates(hass, style) + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() + await async_ensure_triggered_entity_updates(hass, style) + assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE diff --git a/tests/components/template/test_trigger_entity.py b/tests/components/template/test_trigger_entity.py index 99aa2d65df9..65db69fa2b9 100644 --- a/tests/components/template/test_trigger_entity.py +++ b/tests/components/template/test_trigger_entity.py @@ -1,8 +1,28 @@ """Test trigger template entity.""" +import pytest + from homeassistant.components.template import trigger_entity from homeassistant.components.template.coordinator import TriggerUpdateCoordinator +from homeassistant.const import CONF_ICON, CONF_NAME, CONF_STATE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers import template +from homeassistant.helpers.trigger_template_entity import CONF_PICTURE + +_ICON_TEMPLATE = 'mdi:o{{ "n" if value=="on" else "ff" }}' +_PICTURE_TEMPLATE = '/local/picture_o{{ "n" if value=="on" else "ff" }}' + + +class TestEntity(trigger_entity.TriggerEntity): + """Test entity class.""" + + __test__ = False + extra_template_keys = (CONF_STATE,) + + @property + def state(self) -> bool | None: + """Return extra attributes.""" + return self._rendered.get(CONF_STATE) async def test_reference_blueprints_is_none(hass: HomeAssistant) -> None: @@ -11,3 +31,106 @@ async def test_reference_blueprints_is_none(hass: HomeAssistant) -> None: entity = trigger_entity.TriggerEntity(hass, coordinator, {}) assert entity.referenced_blueprint is None + + +async def test_template_state(hass: HomeAssistant) -> None: + """Test manual trigger template entity with a state.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ICON: template.Template(_ICON_TEMPLATE, hass), + CONF_PICTURE: template.Template(_PICTURE_TEMPLATE, hass), + CONF_STATE: template.Template("{{ value == 'on' }}", hass), + } + + coordinator = TriggerUpdateCoordinator(hass, {}) + entity = TestEntity(hass, coordinator, config) + entity.entity_id = "test.entity" + + coordinator._execute_update({"value": STATE_ON}) + entity._handle_coordinator_update() + await hass.async_block_till_done() + + assert entity.state == "True" + assert entity.icon == "mdi:on" + assert entity.entity_picture == "/local/picture_on" + + coordinator._execute_update({"value": STATE_OFF}) + entity._handle_coordinator_update() + await hass.async_block_till_done() + + assert entity.state == "False" + assert entity.icon == "mdi:off" + assert entity.entity_picture == "/local/picture_off" + + +async def test_bad_template_state(hass: HomeAssistant) -> None: + """Test manual trigger template entity with a state.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ICON: template.Template(_ICON_TEMPLATE, hass), + CONF_PICTURE: template.Template(_PICTURE_TEMPLATE, hass), + CONF_STATE: template.Template("{{ x - 1 }}", hass), + } + coordinator = TriggerUpdateCoordinator(hass, {}) + entity = TestEntity(hass, coordinator, config) + entity.entity_id = "test.entity" + + coordinator._execute_update({"x": 1}) + entity._handle_coordinator_update() + await hass.async_block_till_done() + + assert entity.available is True + assert entity.state == "0" + assert entity.icon == "mdi:off" + assert entity.entity_picture == "/local/picture_off" + + coordinator._execute_update({"value": STATE_OFF}) + entity._handle_coordinator_update() + await hass.async_block_till_done() + + assert entity.available is False + assert entity.state is None + assert entity.icon is None + assert entity.entity_picture is None + + +async def test_template_state_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test manual trigger template entity when state render fails.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ICON: template.Template(_ICON_TEMPLATE, hass), + CONF_PICTURE: template.Template(_PICTURE_TEMPLATE, hass), + CONF_STATE: template.Template("{{ incorrect ", hass), + } + + coordinator = TriggerUpdateCoordinator(hass, {}) + entity = TestEntity(hass, coordinator, config) + entity.entity_id = "test.entity" + + coordinator._execute_update({"value": STATE_ON}) + entity._handle_coordinator_update() + await hass.async_block_till_done() + + assert f"Error rendering {CONF_STATE} template for test.entity" in caplog.text + + assert entity.state is None + assert entity.icon is None + assert entity.entity_picture is None + + +async def test_script_variables_from_coordinator(hass: HomeAssistant) -> None: + """Test script variables.""" + coordinator = TriggerUpdateCoordinator(hass, {}) + entity = TestEntity(hass, coordinator, {}) + + assert entity._render_script_variables() == {} + + coordinator.data = {"run_variables": None} + + assert entity._render_script_variables() == {} + + coordinator._execute_update({"value": STATE_ON}) + + assert entity._render_script_variables() == {"value": STATE_ON} diff --git a/tests/components/tesla_fleet/snapshots/test_number.ambr b/tests/components/tesla_fleet/snapshots/test_number.ambr index 1981544a024..a3fccf3a45a 100644 --- a/tests/components/tesla_fleet/snapshots/test_number.ambr +++ b/tests/components/tesla_fleet/snapshots/test_number.ambr @@ -88,7 +88,7 @@ }), 'original_device_class': , 'original_icon': 'mdi:battery-unknown', - 'original_name': 'Off grid reserve', + 'original_name': 'Off-grid reserve', 'platform': 'tesla_fleet', 'previous_unique_id': None, 'supported_features': 0, @@ -101,7 +101,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Energy Site Off grid reserve', + 'friendly_name': 'Energy Site Off-grid reserve', 'icon': 'mdi:battery-unknown', 'max': 100, 'min': 0, diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index 31915630951..b658c1e2271 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -11,6 +11,8 @@ WAKE_UP_ONLINE = {"response": {"state": TeslemetryState.ONLINE}, "error": None} WAKE_UP_ASLEEP = {"response": {"state": TeslemetryState.ASLEEP}, "error": None} PRODUCTS = load_json_object_fixture("products.json", DOMAIN) +PRODUCTS_MODERN = load_json_object_fixture("products.json", DOMAIN) +PRODUCTS_MODERN["response"][0]["command_signing"] = "required" VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN) VEHICLE_DATA_ASLEEP = load_json_object_fixture("vehicle_data.json", DOMAIN) VEHICLE_DATA_ASLEEP["response"]["state"] = TeslemetryState.OFFLINE @@ -43,6 +45,7 @@ METADATA = { "vehicle_device_data", "vehicle_cmds", "vehicle_charging_cmds", + "vehicle_location", "energy_device_data", "energy_cmds", ], diff --git a/tests/components/teslemetry/fixtures/products.json b/tests/components/teslemetry/fixtures/products.json index 56497a6d936..f324aa96366 100644 --- a/tests/components/teslemetry/fixtures/products.json +++ b/tests/components/teslemetry/fixtures/products.json @@ -67,7 +67,7 @@ "webcam_supported": true, "wheel_type": "Pinwheel18CapKit" }, - "command_signing": "allowed", + "command_signing": "off", "release_notes_supported": true }, { diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index a295dc16344..d957bdedcf4 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -11,7 +11,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.energy_site_backup_capable', 'has_entity_name': True, 'hidden_by': None, @@ -58,7 +58,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.energy_site_grid_services_active', '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.energy_site_grid_services_enabled', 'has_entity_name': True, 'hidden_by': None, @@ -140,6 +140,54 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[binary_sensor.energy_site_grid_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.energy_site_grid_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': 'Grid status', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_status', + 'unique_id': '123456-grid_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.energy_site_grid_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Energy Site Grid status', + }), + 'context': , + 'entity_id': 'binary_sensor.energy_site_grid_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensor[binary_sensor.energy_site_storm_watch_active-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -518,6 +566,54 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[binary_sensor.test_cellular-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_cellular', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cellular', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cellular', + 'unique_id': 'LRW3F7EK4NC700000-cellular', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_cellular-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Cellular', + }), + 'context': , + 'entity_id': 'binary_sensor.test_cellular', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_charge_cable-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -566,6 +662,53 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[binary_sensor.test_charge_enable_request-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_charge_enable_request', + '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': 'Charge enable request', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_enable_request', + 'unique_id': 'LRW3F7EK4NC700000-charge_enable_request', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_charge_enable_request-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge enable request', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charge_enable_request', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -755,6 +898,53 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_defrost_for_preconditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_defrost_for_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': 'Defrost for preconditioning', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'defrost_for_preconditioning', + 'unique_id': 'LRW3F7EK4NC700000-defrost_for_preconditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_defrost_for_preconditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Defrost for preconditioning', + }), + 'context': , + 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_drive_rail-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1324,6 +1514,101 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_high_beams-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_high_beams', + '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': 'High beams', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lights_high_beams', + 'unique_id': 'LRW3F7EK4NC700000-lights_high_beams', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_high_beams-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test High beams', + }), + 'context': , + 'entity_id': 'binary_sensor.test_high_beams', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_high_voltage_interlock_loop_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'High voltage interlock loop fault', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvil', + 'unique_id': 'LRW3F7EK4NC700000-hvil', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_high_voltage_interlock_loop_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test High voltage interlock loop fault', + }), + 'context': , + 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_homelink_nearby-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1371,6 +1656,53 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_hvac_auto_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_hvac_auto_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': 'HVAC auto mode', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_auto_mode', + 'unique_id': 'LRW3F7EK4NC700000-hvac_auto_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_hvac_auto_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test HVAC auto mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_hvac_auto_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_located_at_favorite-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1986,6 +2318,53 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_remote_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_remote_start', + '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': 'Remote start', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_start_enabled', + 'unique_id': 'LRW3F7EK4NC700000-remote_start_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_remote_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Remote start', + }), + 'context': , + 'entity_id': 'binary_sensor.test_remote_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_right_hand_drive-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2080,6 +2459,53 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_seat_vent_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_seat_vent_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': 'Seat vent enabled', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'seat_vent_enabled', + 'unique_id': 'LRW3F7EK4NC700000-seat_vent_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_seat_vent_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat vent enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_seat_vent_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_service_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2127,6 +2553,53 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_speed_limited-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_speed_limited', + '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': 'Speed limited', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'speed_limit_mode', + 'unique_id': 'LRW3F7EK4NC700000-speed_limit_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_speed_limited-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Speed limited', + }), + 'context': , + 'entity_id': 'binary_sensor.test_speed_limited', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_status-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2172,7 +2645,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-entry] @@ -2509,6 +2982,54 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[binary_sensor.test_wi_fi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_wi_fi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi', + 'unique_id': 'LRW3F7EK4NC700000-wifi', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_wi_fi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Wi-Fi', + }), + 'context': , + 'entity_id': 'binary_sensor.test_wi_fi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_wiper_heat-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2595,6 +3116,20 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.energy_site_grid_status-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Energy Site Grid status', + }), + 'context': , + 'entity_id': 'binary_sensor.energy_site_grid_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.energy_site_storm_watch_active-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2701,6 +3236,20 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_cellular-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Cellular', + }), + 'context': , + 'entity_id': 'binary_sensor.test_cellular', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_charge_cable-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2715,6 +3264,19 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_charge_enable_request-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge enable request', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charge_enable_request', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_charge_port_cold_weather_mode-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2768,6 +3330,19 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_defrost_for_preconditioning-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Defrost for preconditioning', + }), + 'context': , + 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_drive_rail-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2929,6 +3504,33 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_high_beams-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test High beams', + }), + 'context': , + 'entity_id': 'binary_sensor.test_high_beams', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_high_voltage_interlock_loop_fault-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test High voltage interlock loop fault', + }), + 'context': , + 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_homelink_nearby-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2942,6 +3544,19 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_hvac_auto_mode-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test HVAC auto mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_hvac_auto_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_located_at_favorite-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3115,6 +3730,19 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_remote_start-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Remote start', + }), + 'context': , + 'entity_id': 'binary_sensor.test_remote_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_right_hand_drive-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3141,6 +3769,19 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_seat_vent_enabled-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat vent enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_seat_vent_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_service_mode-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3154,6 +3795,19 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_speed_limited-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Speed limited', + }), + 'context': , + 'entity_id': 'binary_sensor.test_speed_limited', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_status-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3165,7 +3819,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_supercharger_session_trip_planner-statealt] @@ -3264,6 +3918,20 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_wi_fi-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Wi-Fi', + }), + 'context': , + 'entity_id': 'binary_sensor.test_wi_fi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_wiper_heat-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3277,6 +3945,12 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensors_connectivity[binary_sensor.test_cellular-state] + 'on' +# --- +# name: test_binary_sensors_connectivity[binary_sensor.test_wi_fi-state] + 'off' +# --- # name: test_binary_sensors_streaming[binary_sensor.test_driver_seat_belt-state] 'off' # --- @@ -3290,5 +3964,11 @@ 'off' # --- # name: test_binary_sensors_streaming[binary_sensor.test_front_passenger_window-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_rear_driver_window-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_rear_passenger_window-state] 'on' # --- diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index a39e8a0ff74..6b02b2f6d83 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -191,6 +191,7 @@ 'vehicle_device_data', 'vehicle_cmds', 'vehicle_charging_cmds', + 'vehicle_location', 'energy_device_data', 'energy_cmds', ]), diff --git a/tests/components/teslemetry/snapshots/test_number.ambr b/tests/components/teslemetry/snapshots/test_number.ambr index 5ca9feb22f2..2c6705074f3 100644 --- a/tests/components/teslemetry/snapshots/test_number.ambr +++ b/tests/components/teslemetry/snapshots/test_number.ambr @@ -88,7 +88,7 @@ }), 'original_device_class': , 'original_icon': 'mdi:battery-unknown', - 'original_name': 'Off grid reserve', + 'original_name': 'Off-grid reserve', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, @@ -101,7 +101,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Energy Site Off grid reserve', + 'friendly_name': 'Energy Site Off-grid reserve', 'icon': 'mdi:battery-unknown', 'max': 100, 'min': 0, diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index c5d98abc95c..8e9ce51e297 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -2312,7 +2312,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.energy_site_version', 'has_entity_name': True, 'hidden_by': None, @@ -2325,7 +2325,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'version', + 'original_name': 'Version', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, @@ -2337,7 +2337,7 @@ # name: test_sensors[sensor.energy_site_version-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Energy Site version', + 'friendly_name': 'Energy Site Version', }), 'context': , 'entity_id': 'sensor.energy_site_version', @@ -2350,7 +2350,7 @@ # name: test_sensors[sensor.energy_site_version-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Energy Site version', + 'friendly_name': 'Energy Site Version', }), 'context': , 'entity_id': 'sensor.energy_site_version', @@ -4499,6 +4499,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, diff --git a/tests/components/teslemetry/test_binary_sensor.py b/tests/components/teslemetry/test_binary_sensor.py index 5a7126afe1b..0f5588fe323 100644 --- a/tests/components/teslemetry/test_binary_sensor.py +++ b/tests/components/teslemetry/test_binary_sensor.py @@ -73,6 +73,8 @@ async def test_binary_sensors_streaming( "data": { Signal.FD_WINDOW: "WindowStateOpened", Signal.FP_WINDOW: "INVALID_VALUE", + Signal.RD_WINDOW: "WindowStateClosed", + Signal.RP_WINDOW: "WindowStatePartiallyOpen", Signal.DOOR_STATE: { "DoorState": { "DriverFront": True, @@ -98,9 +100,53 @@ async def test_binary_sensors_streaming( for entity_id in ( "binary_sensor.test_front_driver_window", "binary_sensor.test_front_passenger_window", + "binary_sensor.test_rear_driver_window", + "binary_sensor.test_rear_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") + + +async def test_binary_sensors_connectivity( + 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") + + await setup_platform(hass, [Platform.BINARY_SENSOR]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "status": "CONNECTED", + "networkInterface": "cellular", + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "status": "DISCONNECTED", + "networkInterface": "wifi", + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "binary_sensor.test_cellular", + "binary_sensor.test_wi_fi", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") diff --git a/tests/components/teslemetry/test_device_tracker.py b/tests/components/teslemetry/test_device_tracker.py index 38a28092d33..ea0ee08e64f 100644 --- a/tests/components/teslemetry/test_device_tracker.py +++ b/tests/components/teslemetry/test_device_tracker.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import assert_entities, assert_entities_alt, setup_platform -from .const import VEHICLE_DATA_ALT +from .const import METADATA_NOSCOPE, VEHICLE_DATA_ALT @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -42,6 +42,23 @@ async def test_device_tracker_alt( assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_device_tracker_noscope( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_metadata: AsyncMock, + mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, +) -> None: + """Tests that the device tracker entities are correct.""" + + mock_metadata.return_value = METADATA_NOSCOPE + entry = await setup_platform(hass, [Platform.DEVICE_TRACKER]) + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + assert len(entity_entries) == 0 + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_device_tracker_streaming( hass: HomeAssistant, diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index fcf9c76c939..d2ef5c38893 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import ( @@ -10,14 +11,15 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, ) +from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.models import TeslemetryData from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from . import setup_platform -from .const import VEHICLE_DATA_ALT +from .const import PRODUCTS_MODERN, VEHICLE_DATA_ALT ERRORS = [ (InvalidToken, ConfigEntryState.SETUP_ERROR), @@ -130,7 +132,7 @@ async def test_vehicle_stream( mock_add_listener.assert_called() state = hass.states.get("binary_sensor.test_status") - assert state.state == STATE_ON + assert state.state == STATE_UNKNOWN state = hass.states.get("binary_sensor.test_user_present") assert state.state == STATE_OFF @@ -139,11 +141,15 @@ async def test_vehicle_stream( { "vin": VEHICLE_DATA_ALT["response"]["vin"], "vehicle_data": VEHICLE_DATA_ALT["response"], + "state": "online", "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_ON + state = hass.states.get("binary_sensor.test_user_present") assert state.state == STATE_ON @@ -169,3 +175,21 @@ async def test_no_live_status( await setup_platform(hass) assert hass.states.get("sensor.energy_site_grid_power") is None + + +async def test_modern_no_poll( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, + mock_products: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that modern vehicles do not poll vehicle_data.""" + + mock_products.return_value = PRODUCTS_MODERN + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.LOADED + assert mock_vehicle_data.called is False + freezer.tick(VEHICLE_INTERVAL) + assert mock_vehicle_data.called is False + freezer.tick(VEHICLE_INTERVAL) + assert mock_vehicle_data.called is False diff --git a/tests/components/tessie/snapshots/test_number.ambr b/tests/components/tessie/snapshots/test_number.ambr index 0e43695ca78..e865058c4a2 100644 --- a/tests/components/tessie/snapshots/test_number.ambr +++ b/tests/components/tessie/snapshots/test_number.ambr @@ -88,7 +88,7 @@ }), 'original_device_class': , 'original_icon': 'mdi:battery-unknown', - 'original_name': 'Off grid reserve', + 'original_name': 'Off-grid reserve', 'platform': 'tessie', 'previous_unique_id': None, 'supported_features': 0, @@ -101,7 +101,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Energy Site Off grid reserve', + 'friendly_name': 'Energy Site Off-grid reserve', 'icon': 'mdi:battery-unknown', 'max': 100, 'min': 0, diff --git a/tests/components/thermobeacon/__init__.py b/tests/components/thermobeacon/__init__.py index 2f7e220ebaa..32b6d823ec2 100644 --- a/tests/components/thermobeacon/__init__.py +++ b/tests/components/thermobeacon/__init__.py @@ -1,8 +1,48 @@ """Tests for the ThermoBeacon integration.""" -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from uuid import UUID -NOT_THERMOBEACON_SERVICE_INFO = BluetoothServiceInfo( +from bleak.backends.device import BLEDevice +from bluetooth_data_tools import monotonic_time_coarse + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak + + +def make_bluetooth_service_info( + name: str, + manufacturer_data: dict[int, bytes], + service_uuids: list[str], + address: str, + rssi: int, + service_data: dict[UUID, bytes], + source: str, + tx_power: int = 0, + raw: bytes | None = None, +) -> BluetoothServiceInfoBleak: + """Create a BluetoothServiceInfoBleak object for testing.""" + return BluetoothServiceInfoBleak( + name=name, + manufacturer_data=manufacturer_data, + service_uuids=service_uuids, + address=address, + rssi=rssi, + service_data=service_data, + source=source, + device=BLEDevice( + name=name, + address=address, + details={}, + rssi=rssi, + ), + time=monotonic_time_coarse(), + advertisement=None, + connectable=True, + tx_power=tx_power, + raw=raw, + ) + + +NOT_THERMOBEACON_SERVICE_INFO = make_bluetooth_service_info( name="Not it", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -12,7 +52,7 @@ NOT_THERMOBEACON_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -THERMOBEACON_SERVICE_INFO = BluetoothServiceInfo( +THERMOBEACON_SERVICE_INFO = make_bluetooth_service_info( name="ThermoBeacon", address="aa:bb:cc:dd:ee:ff", rssi=-60, diff --git a/tests/components/thermopro/__init__.py b/tests/components/thermopro/__init__.py index d3cba26858f..7ac593e6336 100644 --- a/tests/components/thermopro/__init__.py +++ b/tests/components/thermopro/__init__.py @@ -1,8 +1,48 @@ """Tests for the ThermoPro integration.""" -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from uuid import UUID -NOT_THERMOPRO_SERVICE_INFO = BluetoothServiceInfo( +from bleak.backends.device import BLEDevice +from bluetooth_data_tools import monotonic_time_coarse + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak + + +def make_bluetooth_service_info( + name: str, + manufacturer_data: dict[int, bytes], + service_uuids: list[str], + address: str, + rssi: int, + service_data: dict[UUID, bytes], + source: str, + tx_power: int = 0, + raw: bytes | None = None, +) -> BluetoothServiceInfoBleak: + """Create a BluetoothServiceInfoBleak object for testing.""" + return BluetoothServiceInfoBleak( + name=name, + manufacturer_data=manufacturer_data, + service_uuids=service_uuids, + address=address, + rssi=rssi, + service_data=service_data, + source=source, + device=BLEDevice( + name=name, + address=address, + details={}, + rssi=rssi, + ), + time=monotonic_time_coarse(), + advertisement=None, + connectable=True, + tx_power=tx_power, + raw=raw, + ) + + +NOT_THERMOPRO_SERVICE_INFO = make_bluetooth_service_info( name="Not it", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -13,7 +53,7 @@ NOT_THERMOPRO_SERVICE_INFO = BluetoothServiceInfo( ) -TP357_SERVICE_INFO = BluetoothServiceInfo( +TP357_SERVICE_INFO = make_bluetooth_service_info( name="TP357 (2142)", manufacturer_data={61890: b"\x00\x1d\x02,"}, service_uuids=[], @@ -23,7 +63,7 @@ TP357_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -TP358_SERVICE_INFO = BluetoothServiceInfo( +TP358_SERVICE_INFO = make_bluetooth_service_info( name="TP358 (4221)", manufacturer_data={61890: b"\x00\x1d\x02,"}, service_uuids=[], @@ -33,7 +73,7 @@ TP358_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -TP962R_SERVICE_INFO = BluetoothServiceInfo( +TP962R_SERVICE_INFO = make_bluetooth_service_info( name="TP962R (0000)", manufacturer_data={14081: b"\x00;\x0b7\x00"}, service_uuids=["72fbb631-6f6b-d1ba-db55-2ee6fdd942bd"], @@ -43,7 +83,7 @@ TP962R_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -TP962R_SERVICE_INFO_2 = BluetoothServiceInfo( +TP962R_SERVICE_INFO_2 = make_bluetooth_service_info( name="TP962R (0000)", manufacturer_data={17152: b"\x00\x17\nC\x00", 14081: b"\x00;\x0b7\x00"}, service_uuids=["72fbb631-6f6b-d1ba-db55-2ee6fdd942bd"], diff --git a/tests/components/threshold/test_config_flow.py b/tests/components/threshold/test_config_flow.py index c13717800bf..5d9d22c3f81 100644 --- a/tests/components/threshold/test_config_flow.py +++ b/tests/components/threshold/test_config_flow.py @@ -11,7 +11,7 @@ from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value from tests.typing import WebSocketGenerator @@ -88,17 +88,6 @@ async def test_fail(hass: HomeAssistant, extra_input_data, error) -> None: assert result["errors"] == {"base": error} -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - async def test_options(hass: HomeAssistant) -> None: """Test reconfiguring.""" input_sensor = "sensor.input" @@ -125,9 +114,9 @@ async def test_options(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "hysteresis") == 0.0 - assert get_suggested(schema, "lower") == -2.0 - assert get_suggested(schema, "upper") is None + assert get_schema_suggested_value(schema, "hysteresis") == 0.0 + assert get_schema_suggested_value(schema, "lower") == -2.0 + assert get_schema_suggested_value(schema, "upper") is None result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/tile/conftest.py b/tests/components/tile/conftest.py index 4391853c878..21ca2c90fa1 100644 --- a/tests/components/tile/conftest.py +++ b/tests/components/tile/conftest.py @@ -26,6 +26,7 @@ def tile() -> AsyncMock: mock.latitude = 1 mock.longitude = 1 mock.altitude = 0 + mock.accuracy = 13.496111 mock.lost = False mock.last_timestamp = datetime(2020, 8, 12, 17, 55, 26) mock.lost_timestamp = datetime(1969, 12, 31, 19, 0, 0) @@ -42,8 +43,8 @@ def tile() -> AsyncMock: "hardware_version": "02.09", "kind": "TILE", "last_timestamp": datetime(2020, 8, 12, 17, 55, 26), - "latitude": 0, - "longitude": 0, + "latitude": 1, + "longitude": 1, "lost": False, "lost_timestamp": datetime(1969, 12, 31, 19, 0, 0), "name": "Wallet", diff --git a/tests/components/tile/snapshots/test_device_tracker.ambr b/tests/components/tile/snapshots/test_device_tracker.ambr index f5de1511c99..3f94f679f10 100644 --- a/tests/components/tile/snapshots/test_device_tracker.ambr +++ b/tests/components/tile/snapshots/test_device_tracker.ambr @@ -38,7 +38,7 @@ 'attributes': ReadOnlyDict({ 'altitude': 0, 'friendly_name': 'Wallet', - 'gps_accuracy': 1, + 'gps_accuracy': 13.496111, 'is_lost': False, 'last_lost_timestamp': datetime.datetime(1970, 1, 1, 3, 0, tzinfo=datetime.timezone.utc), 'last_timestamp': datetime.datetime(2020, 8, 13, 0, 55, 26, tzinfo=datetime.timezone.utc), diff --git a/tests/components/tod/test_config_flow.py b/tests/components/tod/test_config_flow.py index 81f10061774..125a969c09d 100644 --- a/tests/components/tod/test_config_flow.py +++ b/tests/components/tod/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.tod.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("platform", ["sensor"]) @@ -55,17 +55,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: assert config_entry.title == "My tod" -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - @pytest.mark.freeze_time("2022-03-16 17:37:00", tz_offset=-7) async def test_options(hass: HomeAssistant) -> None: """Test reconfiguring.""" @@ -88,8 +77,8 @@ async def test_options(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "after_time") == "10:00" - assert get_suggested(schema, "before_time") == "18:05" + assert get_schema_suggested_value(schema, "after_time") == "10:00" + assert get_schema_suggested_value(schema, "before_time") == "18:05" result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 11ef3d6f044..adada97a9e4 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -160,9 +160,18 @@ async def test_unsupported_websocket( assert resp.get("error", {}).get("code") == "not_found" +@pytest.mark.parametrize( + ("new_item_name"), + [ + ("New item"), + ("New item "), + (" New item"), + ], +) async def test_add_item_service( hass: HomeAssistant, test_entity: TodoListEntity, + new_item_name: str, ) -> None: """Test adding an item in a To-do list.""" @@ -171,7 +180,7 @@ async def test_add_item_service( await hass.services.async_call( DOMAIN, TodoServices.ADD_ITEM, - {ATTR_ITEM: "New item"}, + {ATTR_ITEM: new_item_name}, target={ATTR_ENTITY_ID: "todo.entity1"}, blocking=True, ) @@ -209,6 +218,7 @@ async def test_add_item_service_raises( [ ({}, vol.Invalid, "required key not provided"), ({ATTR_ITEM: ""}, vol.Invalid, "length of value must be at least 1"), + ({ATTR_ITEM: " "}, vol.Invalid, "length of value must be at least 1"), ( {ATTR_ITEM: "Submit forms", ATTR_DESCRIPTION: "Submit tax forms"}, ServiceValidationError, @@ -331,9 +341,18 @@ async def test_add_item_service_extended_fields( assert item == expected_item +@pytest.mark.parametrize( + ("new_item_name"), + [ + ("Updated item"), + ("Updated item "), + (" Updated item "), + ], +) async def test_update_todo_item_service_by_id( hass: HomeAssistant, test_entity: TodoListEntity, + new_item_name: str, ) -> None: """Test updating an item in a To-do list.""" @@ -342,7 +361,7 @@ async def test_update_todo_item_service_by_id( await hass.services.async_call( DOMAIN, TodoServices.UPDATE_ITEM, - {ATTR_ITEM: "1", ATTR_RENAME: "Updated item", ATTR_STATUS: "completed"}, + {ATTR_ITEM: "1", ATTR_RENAME: new_item_name, ATTR_STATUS: "completed"}, target={ATTR_ENTITY_ID: "todo.entity1"}, blocking=True, ) diff --git a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr index a63319a6c76..ac32b50762f 100644 --- a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr @@ -36,19 +36,11 @@ # name: test_attributes[alarm_control_panel.test-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'ac_loss': False, 'changed_by': None, 'code_arm_required': False, 'code_format': None, - 'cover_tampered': False, 'friendly_name': 'test', - 'location_id': 123456, - 'location_name': 'test', - 'low_battery': False, - 'partition': 1, 'supported_features': , - 'triggered_source': None, - 'triggered_zone': None, }), 'context': , 'entity_id': 'alarm_control_panel.test', @@ -95,19 +87,11 @@ # name: test_attributes[alarm_control_panel.test_partition_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'ac_loss': False, 'changed_by': None, 'code_arm_required': False, 'code_format': None, - 'cover_tampered': False, 'friendly_name': 'test Partition 2', - 'location_id': 123456, - 'location_name': 'test partition 2', - 'low_battery': False, - 'partition': 2, 'supported_features': , - 'triggered_source': None, - 'triggered_zone': None, }), 'context': , 'entity_id': 'alarm_control_panel.test_partition_2', diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index bc76f7243ca..6ba067b8ae2 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -50,9 +50,6 @@ from .common import ( RESPONSE_DISARMED, RESPONSE_DISARMING, RESPONSE_SUCCESS, - RESPONSE_TRIGGERED_CARBON_MONOXIDE, - RESPONSE_TRIGGERED_FIRE, - RESPONSE_TRIGGERED_POLICE, RESPONSE_UNKNOWN, RESPONSE_USER_CODE_INVALID, TOTALCONNECT_REQUEST, @@ -195,7 +192,7 @@ async def test_arm_home_instant_failure(hass: HomeAssistant) -> None: DOMAIN, SERVICE_ALARM_ARM_HOME_INSTANT, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{err.value}" == "Usercode is invalid, did not arm home instant" + assert str(err.value) == "Usercode is invalid, did not arm home instant" assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED # should have started a re-auth flow assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 @@ -513,45 +510,6 @@ async def test_disarming(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMING -async def test_triggered_fire(hass: HomeAssistant) -> None: - """Test triggered by fire.""" - responses = [RESPONSE_TRIGGERED_FIRE] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) - assert state.state == AlarmControlPanelState.TRIGGERED - assert state.attributes.get("triggered_source") == "Fire/Smoke" - assert mock_request.call_count == 1 - - -async def test_triggered_police(hass: HomeAssistant) -> None: - """Test triggered by police.""" - responses = [RESPONSE_TRIGGERED_POLICE] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) - assert state.state == AlarmControlPanelState.TRIGGERED - assert state.attributes.get("triggered_source") == "Police/Medical" - assert mock_request.call_count == 1 - - -async def test_triggered_carbon_monoxide(hass: HomeAssistant) -> None: - """Test triggered by carbon monoxide.""" - responses = [RESPONSE_TRIGGERED_CARBON_MONOXIDE] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) - assert state.state == AlarmControlPanelState.TRIGGERED - assert state.attributes.get("triggered_source") == "Carbon Monoxide" - assert mock_request.call_count == 1 - - async def test_armed_custom(hass: HomeAssistant) -> None: """Test armed custom.""" responses = [RESPONSE_ARMED_CUSTOM] diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 72198e579a1..73fcdc8565d 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -95,7 +95,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Auto off at', + 'original_name': 'Auto-off at', 'platform': 'tplink', 'previous_unique_id': None, 'supported_features': 0, @@ -108,7 +108,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', - 'friendly_name': 'my_device Auto off at', + 'friendly_name': 'my_device Auto-off at', }), 'context': , 'entity_id': 'sensor.my_device_auto_off_at', diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index bd89da8e841..fd398434a07 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -108,7 +108,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Auto off enabled', + 'original_name': 'Auto-off enabled', 'platform': 'tplink', 'previous_unique_id': None, 'supported_features': 0, @@ -120,7 +120,7 @@ # name: test_states[switch.my_device_auto_off_enabled-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my_device Auto off enabled', + 'friendly_name': 'my_device Auto-off enabled', }), 'context': , 'entity_id': 'switch.my_device_auto_off_enabled', @@ -155,7 +155,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Auto update enabled', + 'original_name': 'Auto-update enabled', 'platform': 'tplink', 'previous_unique_id': None, 'supported_features': 0, @@ -167,7 +167,7 @@ # name: test_states[switch.my_device_auto_update_enabled-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my_device Auto update enabled', + 'friendly_name': 'my_device Auto-update enabled', }), 'context': , 'entity_id': 'switch.my_device_auto_update_enabled', diff --git a/tests/components/tplink_omada/snapshots/test_switch.ambr b/tests/components/tplink_omada/snapshots/test_switch.ambr index dde196deaaf..eae97f2aae1 100644 --- a/tests/components/tplink_omada/snapshots/test_switch.ambr +++ b/tests/components/tplink_omada/snapshots/test_switch.ambr @@ -2,7 +2,7 @@ # name: test_gateway_api_fail_disables_switch_entities StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Router Port 4 Internet Connected', + 'friendly_name': 'Test Router Port 4 Internet connected', }), 'context': , 'entity_id': 'switch.test_router_port_4_internet_connected', @@ -15,7 +15,7 @@ # name: test_gateway_connect_ipv4_switch StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Router Port 4 Internet Connected', + 'friendly_name': 'Test Router Port 4 Internet connected', }), 'context': , 'entity_id': 'switch.test_router_port_4_internet_connected', @@ -28,7 +28,7 @@ # name: test_gateway_port_change_disables_switch_entities StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Router Port 4 Internet Connected', + 'friendly_name': 'Test Router Port 4 Internet connected', }), 'context': , 'entity_id': 'switch.test_router_port_4_internet_connected', diff --git a/tests/components/tractive/conftest.py b/tests/components/tractive/conftest.py index 88c68a4b62f..f32aaa84349 100644 --- a/tests/components/tractive/conftest.py +++ b/tests/components/tractive/conftest.py @@ -29,6 +29,7 @@ def mock_tractive_client() -> Generator[AsyncMock]: "tracker_id": "device_id_123", "hardware": {"battery_level": 88}, "tracker_state": "operational", + "tracker_state_reason": "POWER_SAVING", "charging_state": "CHARGING", } entry.runtime_data.client._send_hardware_update(event) diff --git a/tests/components/tractive/snapshots/test_binary_sensor.ambr b/tests/components/tractive/snapshots/test_binary_sensor.ambr index 761626347a7..c7252da7a3b 100644 --- a/tests/components/tractive/snapshots/test_binary_sensor.ambr +++ b/tests/components/tractive/snapshots/test_binary_sensor.ambr @@ -47,3 +47,50 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[binary_sensor.test_pet_tracker_power_saving-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_pet_tracker_power_saving', + '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': 'Tracker power saving', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tracker_power_saving', + 'unique_id': 'pet_id_123_power_saving', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_pet_tracker_power_saving-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pet Tracker power saving', + }), + 'context': , + 'entity_id': 'binary_sensor.test_pet_tracker_power_saving', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py index 99c698771f7..c21db66dfac 100644 --- a/tests/components/tts/common.py +++ b/tests/components/tts/common.py @@ -42,6 +42,7 @@ from tests.typing import ClientSessionGenerator DEFAULT_LANG = "en_US" SUPPORT_LANGUAGES = ["de_CH", "de_DE", "en_GB", "en_US"] TEST_DOMAIN = "test" +MOCK_DATA = b"123" def mock_tts_get_cache_files_fixture_helper() -> Generator[MagicMock]: @@ -164,7 +165,7 @@ class BaseProvider: self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load TTS dat.""" - return ("mp3", b"") + return ("mp3", MOCK_DATA) class MockTTSProvider(BaseProvider, Provider): diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 4e17bc68a5e..ea281506f3a 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -27,6 +27,7 @@ from homeassistant.util import dt as dt_util from .common import ( DEFAULT_LANG, + MOCK_DATA, TEST_DOMAIN, MockResultStream, MockTTS, @@ -808,7 +809,7 @@ async def test_service_receive_voice( await hass.async_block_till_done() client = await hass_client() req = await client.get(url) - tts_data = b"" + tts_data = MOCK_DATA tts_data = tts.SpeechManager.write_tags( f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3", tts_data, @@ -879,7 +880,7 @@ async def test_service_receive_voice_german( await hass.async_block_till_done() client = await hass_client() req = await client.get(url) - tts_data = b"" + tts_data = MOCK_DATA tts_data = tts.SpeechManager.write_tags( "42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_{expected_url_suffix}.mp3", tts_data, @@ -1021,7 +1022,7 @@ async def test_setup_legacy_cache_dir( """Set up a TTS platform with cache and call service without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - tts_data = b"" + tts_data = MOCK_DATA cache_file = ( mock_tts_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ) @@ -1059,7 +1060,7 @@ async def test_setup_cache_dir( """Set up a TTS platform with cache and call service without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - tts_data = b"" + tts_data = MOCK_DATA cache_file = mock_tts_cache_dir / ( "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_tts.test.mp3" ) @@ -1165,7 +1166,7 @@ async def test_legacy_cannot_retrieve_without_token( hass_client: ClientSessionGenerator, ) -> None: """Verify that a TTS cannot be retrieved by filename directly.""" - tts_data = b"" + tts_data = MOCK_DATA cache_file = ( mock_tts_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ) @@ -1188,7 +1189,7 @@ async def test_cannot_retrieve_without_token( hass_client: ClientSessionGenerator, ) -> None: """Verify that a TTS cannot be retrieved by filename directly.""" - tts_data = b"" + tts_data = MOCK_DATA cache_file = mock_tts_cache_dir / ( "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_tts.test.mp3" ) @@ -1521,6 +1522,45 @@ async def test_fetching_in_async( ) +@pytest.mark.parametrize( + ("setup", "engine_id"), + [ + ("mock_setup", "test"), + ], + indirect=["setup"], +) +async def test_ws_list_engines_filter_deprecated( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup: str, + engine_id: str, +) -> None: + """Test listing tts engines and supported languages.""" + client = await hass_ws_client() + + await client.send_json_auto_id({"type": "tts/engine/list"}) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "providers": [ + { + "name": "Test", + "engine_id": engine_id, + "supported_languages": ["de_CH", "de_DE", "en_GB", "en_US"], + } + ] + } + + hass.data[tts.DATA_TTS_MANAGER].providers[engine_id].has_entity = True + + await client.send_json_auto_id({"type": "tts/engine/list"}) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"providers": []} + + @pytest.mark.parametrize( ("setup", "engine_id", "extra_data"), [ @@ -1841,10 +1881,41 @@ async def test_default_engine_prefer_cloud_entity( async def test_stream(hass: HomeAssistant, mock_tts_entity: MockTTSEntity) -> None: """Test creating streams.""" await mock_config_entry_setup(hass, mock_tts_entity) + stream = tts.async_create_stream(hass, mock_tts_entity.entity_id) assert stream.language == mock_tts_entity.default_language assert stream.options == (mock_tts_entity.default_options or {}) assert tts.async_get_stream(hass, stream.token) is stream + stream.async_set_message("beer") + result_data = b"".join([chunk async for chunk in stream.async_stream_result()]) + assert result_data == MOCK_DATA + + async def async_stream_tts_audio( + request: tts.TTSAudioRequest, + ) -> tts.TTSAudioResponse: + """Mock stream TTS audio.""" + + async def gen_data(): + async for msg in request.message_gen: + yield msg.encode() + + return tts.TTSAudioResponse( + extension="mp3", + data_gen=gen_data(), + ) + + mock_tts_entity.async_stream_tts_audio = async_stream_tts_audio + + async def stream_message(): + """Mock stream message.""" + yield "he" + yield "ll" + yield "o" + + stream = tts.async_create_stream(hass, mock_tts_entity.entity_id) + stream.async_set_message_stream(stream_message()) + result_data = b"".join([chunk async for chunk in stream.async_stream_result()]) + assert result_data == b"hello" data = b"beer" stream2 = MockResultStream(hass, "wav", data) diff --git a/tests/components/tts/test_media_source.py b/tests/components/tts/test_media_source.py index 9e50cc6b512..eb4b09cab5b 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -9,9 +9,8 @@ import pytest from homeassistant.components import media_source from homeassistant.components.media_player import BrowseError from homeassistant.components.tts.media_source import ( - MediaSourceOptions, generate_media_source_id, - media_source_id_to_kwargs, + parse_media_source_id, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -79,6 +78,7 @@ async def test_browsing(hass: HomeAssistant, setup: str) -> None: assert item_child.children is None assert item_child.can_play is False assert item_child.can_expand is True + assert item_child.thumbnail == "https://brands.home-assistant.io/_/test/logo.png" item_child = await media_source.async_browse_media( hass, item.children[0].media_content_id + "?message=bla" @@ -115,6 +115,13 @@ async def test_legacy_resolving( await mock_setup(hass, mock_provider) mock_get_tts_audio = mock_provider.get_tts_audio + mock_provider.has_entity = True + root = await media_source.async_browse_media(hass, "media-source://tts") + assert len(root.children) == 0 + mock_provider.has_entity = False + root = await media_source.async_browse_media(hass, "media-source://tts") + assert len(root.children) == 1 + mock_get_tts_audio.reset_mock() media_id = "media-source://tts/test?message=Hello%20World" media = await media_source.async_resolve_media(hass, media_id, None) @@ -249,13 +256,13 @@ async def test_resolving_errors(hass: HomeAssistant, setup: str, engine: str) -> ], indirect=["setup"], ) -async def test_generate_media_source_id_and_media_source_id_to_kwargs( +async def test_generate_media_source_id_and_parse_media_source_id( hass: HomeAssistant, setup: str, result_engine: str, ) -> None: - """Test media_source_id and media_source_id_to_kwargs.""" - kwargs: MediaSourceOptions = { + """Test media_source_id and parse_media_source_id.""" + kwargs = { "engine": None, "message": "hello", "language": "en_US", @@ -263,12 +270,14 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "cache": True, } media_source_id = generate_media_source_id(hass, **kwargs) - assert media_source_id_to_kwargs(media_source_id) == { - "engine": result_engine, + assert parse_media_source_id(media_source_id) == { "message": "hello", - "language": "en_US", - "options": {"age": 5}, - "use_file_cache": True, + "options": { + "engine": result_engine, + "language": "en_US", + "options": {"age": 5}, + "use_file_cache": True, + }, } kwargs = { @@ -279,12 +288,14 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "cache": True, } media_source_id = generate_media_source_id(hass, **kwargs) - assert media_source_id_to_kwargs(media_source_id) == { - "engine": result_engine, + assert parse_media_source_id(media_source_id) == { "message": "hello", - "language": "en_US", - "options": {"age": [5, 6]}, - "use_file_cache": True, + "options": { + "engine": result_engine, + "language": "en_US", + "options": {"age": [5, 6]}, + "use_file_cache": True, + }, } kwargs = { @@ -295,10 +306,12 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "cache": True, } media_source_id = generate_media_source_id(hass, **kwargs) - assert media_source_id_to_kwargs(media_source_id) == { - "engine": result_engine, + assert parse_media_source_id(media_source_id) == { "message": "hello", - "language": "en_US", - "options": {"age": {"k1": [5, 6], "k2": "v2"}}, - "use_file_cache": True, + "options": { + "engine": result_engine, + "language": "en_US", + "options": {"age": {"k1": [5, 6], "k2": "v2"}}, + "use_file_cache": True, + }, } diff --git a/tests/components/unifi/snapshots/test_diagnostics.ambr b/tests/components/unifi/snapshots/test_diagnostics.ambr index aa7337be0ba..04aec0541b9 100644 --- a/tests/components/unifi/snapshots/test_diagnostics.ambr +++ b/tests/components/unifi/snapshots/test_diagnostics.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entry_diagnostics[dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0] +# name: test_entry_diagnostics[wlan_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0] dict({ 'clients': dict({ '00:00:00:00:00:00': dict({ @@ -128,6 +128,82 @@ }), 'role_is_admin': True, 'wlans': dict({ + '67f2eaec026b2c2893c41b2a': dict({ + '_id': '67f2eaec026b2c2893c41b2a', + 'ap_group_ids': list([ + '67f2e03f7c572754fa1a249e', + ]), + 'ap_group_mode': 'all', + 'bc_filter_list': '**REDACTED**', + 'bss_transition': True, + 'dtim_6e': 3, + 'dtim_mode': 'default', + 'dtim_na': 3, + 'dtim_ng': 1, + 'enabled': True, + 'enhanced_iot': False, + 'fast_roaming_enabled': False, + 'group_rekey': 3600, + 'hide_ssid': False, + 'hotspot2conf_enabled': False, + 'iapp_enabled': True, + 'is_guest': False, + 'l2_isolation': False, + 'mac_filter_enabled': False, + 'mac_filter_list': list([ + ]), + 'mac_filter_policy': 'allow', + 'mcastenhance_enabled': False, + 'minrate_na_advertising_rates': False, + 'minrate_na_data_rate_kbps': 6000, + 'minrate_na_enabled': False, + 'minrate_ng_advertising_rates': False, + 'minrate_ng_data_rate_kbps': 1000, + 'minrate_ng_enabled': True, + 'minrate_setting_preference': 'auto', + 'mlo_enabled': False, + 'name': 'devices', + 'networkconf_id': '67f2e03f7c572754fa1a2498', + 'no2ghz_oui': True, + 'passphrase_autogenerated': True, + 'pmf_mode': 'disabled', + 'private_preshared_keys': list([ + dict({ + 'networkconf_id': '67f2e03f7c572754fa1a2498', + 'password': '**REDACTED**', + }), + ]), + 'private_preshared_keys_enabled': True, + 'proxy_arp': False, + 'radius_das_enabled': False, + 'radius_mac_auth_enabled': False, + 'radius_macacl_format': 'none_lower', + 'sae_anti_clogging': 5, + 'sae_groups': list([ + ]), + 'sae_psk': list([ + ]), + 'sae_sync': 5, + 'schedule': list([ + ]), + 'schedule_with_duration': list([ + ]), + 'security': 'wpapsk', + 'setting_preference': 'manual', + 'site_id': '67f2e00e7c572754fa1a247e', + 'uapsd_enabled': False, + 'usergroup_id': '67f2e03f7c572754fa1a2499', + 'wlan_band': '2g', + 'wlan_bands': list([ + '2g', + ]), + 'wpa3_fast_roaming': False, + 'wpa3_support': False, + 'wpa3_transition': False, + 'wpa_enc': 'ccmp', + 'wpa_mode': 'wpa2', + 'x_passphrase': '**REDACTED**', + }), }), }) # --- diff --git a/tests/components/unifi/test_diagnostics.py b/tests/components/unifi/test_diagnostics.py index 80359a9c75c..e9fd86f0f8b 100644 --- a/tests/components/unifi/test_diagnostics.py +++ b/tests/components/unifi/test_diagnostics.py @@ -103,6 +103,75 @@ DPI_GROUP_DATA = [ "dpiapp_ids": ["5f976f62e3c58f018ec7e17d"], } ] +WLAN_DATA = [ + { + "setting_preference": "manual", + "wpa3_support": False, + "dtim_6e": 3, + "minrate_na_advertising_rates": False, + "wpa_mode": "wpa2", + "minrate_setting_preference": "auto", + "minrate_ng_advertising_rates": False, + "hotspot2conf_enabled": False, + "radius_das_enabled": False, + "mlo_enabled": False, + "group_rekey": 3600, + "radius_macacl_format": "none_lower", + "pmf_mode": "disabled", + "wpa3_transition": False, + "passphrase_autogenerated": True, + "private_preshared_keys": [ + { + "password": "should be redacted", + "networkconf_id": "67f2e03f7c572754fa1a2498", + } + ], + "mcastenhance_enabled": False, + "usergroup_id": "67f2e03f7c572754fa1a2499", + "proxy_arp": False, + "sae_sync": 5, + "iapp_enabled": True, + "uapsd_enabled": False, + "enhanced_iot": False, + "name": "devices", + "site_id": "67f2e00e7c572754fa1a247e", + "hide_ssid": False, + "wlan_band": "2g", + "_id": "67f2eaec026b2c2893c41b2a", + "private_preshared_keys_enabled": True, + "no2ghz_oui": True, + "networkconf_id": "67f2e03f7c572754fa1a2498", + "is_guest": False, + "dtim_na": 3, + "minrate_na_enabled": False, + "sae_groups": [], + "enabled": True, + "sae_psk": [], + "wlan_bands": ["2g"], + "mac_filter_policy": "allow", + "security": "wpapsk", + "ap_group_ids": ["67f2e03f7c572754fa1a249e"], + "l2_isolation": False, + "minrate_ng_enabled": True, + "bss_transition": True, + "minrate_ng_data_rate_kbps": 1000, + "radius_mac_auth_enabled": False, + "schedule_with_duration": [], + "wpa3_fast_roaming": False, + "ap_group_mode": "all", + "fast_roaming_enabled": False, + "wpa_enc": "ccmp", + "mac_filter_list": [], + "dtim_mode": "default", + "schedule": [], + "bc_filter_list": "should be redacted", + "minrate_na_data_rate_kbps": 6000, + "mac_filter_enabled": False, + "sae_anti_clogging": 5, + "dtim_ng": 1, + "x_passphrase": "should be redacted", + } +] @pytest.mark.parametrize( @@ -119,6 +188,7 @@ DPI_GROUP_DATA = [ @pytest.mark.parametrize("device_payload", [DEVICE_DATA]) @pytest.mark.parametrize("dpi_app_payload", [DPI_APP_DATA]) @pytest.mark.parametrize("dpi_group_payload", [DPI_GROUP_DATA]) +@pytest.mark.parametrize("wlan_payload", [WLAN_DATA]) async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 3a8d5d952ce..3aa441659b0 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -407,7 +407,7 @@ async def test_binary_sensor_update_mount_type_garage( ) -> None: """Test binary_sensor motion entity.""" - await init_entry(hass, ufp, [sensor_all], debug=True) + await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) _, entity_id = ids_from_device_description( diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 7dd0362f17c..06ffe16ab87 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -25,7 +25,6 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityDescription -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -167,7 +166,6 @@ async def init_entry( ufp: MockUFPFixture, devices: Sequence[ProtectAdoptableDeviceModel], regenerate_ids: bool = True, - debug: bool = False, ) -> None: """Initialize Protect entry with given devices.""" @@ -175,14 +173,6 @@ async def init_entry( for device in devices: add_device(ufp.api.bootstrap, device, regenerate_ids) - if debug: - assert await async_setup_component(hass, "logger", {"logger": {}}) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.unifiprotect": "DEBUG"}, - blocking=True, - ) await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/uptimerobot/test_binary_sensor.py b/tests/components/uptimerobot/test_binary_sensor.py index 4b27ab5ff05..3de9b9ec399 100644 --- a/tests/components/uptimerobot/test_binary_sensor.py +++ b/tests/components/uptimerobot/test_binary_sensor.py @@ -34,8 +34,8 @@ async def test_presentation(hass: HomeAssistant) -> None: assert entity.attributes["target"] == MOCK_UPTIMEROBOT_MONITOR["url"] -async def test_unaviable_on_update_failure(hass: HomeAssistant) -> None: - """Test entity unaviable on update failure.""" +async def test_unavailable_on_update_failure(hass: HomeAssistant) -> None: + """Test entity unavailable on update failure.""" await setup_uptimerobot_integration(hass) entity = hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY) diff --git a/tests/components/uptimerobot/test_config_flow.py b/tests/components/uptimerobot/test_config_flow.py index 3ba5ad696a6..c7ae6a5d772 100644 --- a/tests/components/uptimerobot/test_config_flow.py +++ b/tests/components/uptimerobot/test_config_flow.py @@ -24,8 +24,8 @@ from .common import ( from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_user(hass: HomeAssistant) -> None: + """Test user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -56,8 +56,8 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_read_only(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_user_key_read_only(hass: HomeAssistant) -> None: + """Test user flow with read only key.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -87,8 +87,8 @@ async def test_form_read_only(hass: HomeAssistant) -> None: (UptimeRobotAuthenticationException, "invalid_api_key"), ], ) -async def test_form_exception_thrown(hass: HomeAssistant, exception, error_key) -> None: - """Test that we handle exceptions.""" +async def test_exception_thrown(hass: HomeAssistant, exception, error_key) -> None: + """Test user flow throwing exceptions.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -106,10 +106,8 @@ async def test_form_exception_thrown(hass: HomeAssistant, exception, error_key) assert result2["errors"]["base"] == error_key -async def test_form_api_error( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test we handle unexpected error.""" +async def test_api_error(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test expected API error is catch.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py index 187178de78d..435b0737c6d 100644 --- a/tests/components/uptimerobot/test_init.py +++ b/tests/components/uptimerobot/test_init.py @@ -239,7 +239,6 @@ async def test_device_management( freezer.tick(COORDINATOR_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - await hass.async_block_till_done() devices = dr.async_entries_for_config_entry(device_registry, mock_entry.entry_id) assert len(devices) == 1 diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 4901e069aee..01fd80acc0e 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -10,7 +10,7 @@ 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 tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("platform", ["sensor"]) @@ -253,17 +253,6 @@ async def test_always_available(hass: HomeAssistant) -> None: } -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - async def test_options(hass: HomeAssistant) -> None: """Test reconfiguring.""" input_sensor1_entity_id = "sensor.input1" @@ -293,8 +282,8 @@ async def test_options(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "source") == input_sensor1_entity_id - assert get_suggested(schema, "periodically_resetting") is True + assert get_schema_suggested_value(schema, "source") == input_sensor1_entity_id + assert get_schema_suggested_value(schema, "periodically_resetting") is True result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index c671969c5ac..2de2ee553b3 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -43,6 +43,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -1637,8 +1638,21 @@ async def _test_self_reset( now += timedelta(seconds=30) with freeze_time(now): + # Listen for events and check that state in the first event after reset is actually 0, issue #142053 + events = [] + + async def handle_energy_bill_event(event): + events.append(event) + + unsub = async_track_state_change_event( + hass, + "sensor.energy_bill", + handle_energy_bill_event, + ) + async_fire_time_changed(hass, now) await hass.async_block_till_done() + unsub() hass.states.async_set( entity_id, 6, @@ -1654,6 +1668,10 @@ async def _test_self_reset( state.attributes.get("last_reset") == dt_util.as_utc(now).isoformat() ) # last_reset is kept in UTC assert state.state == "3" + # In first event state should be 0 + assert len(events) == 2 + assert events[0].data.get("new_state").state == "0" + assert events[1].data.get("new_state").state == "0" else: assert state.attributes.get("last_period") == "0" assert state.state == "5" diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 39a92778727..5795c977120 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -15,6 +15,8 @@ ENTITY_HUMIDIFIER_MIST_LEVEL = "number.humidifier_200s_mist_level" ENTITY_HUMIDIFIER_HUMIDITY = "sensor.humidifier_200s_humidity" ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT = "select.humidifier_300s_night_light_level" +ENTITY_SWITCH_DISPLAY = "switch.humidifier_200s_display" + ALL_DEVICES = load_json_object_fixture("vesync-devices.json", DOMAIN) ALL_DEVICE_NAMES: list[str] = [ dev["deviceName"] for dev in ALL_DEVICES["result"]["list"] @@ -27,7 +29,11 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") ], "Air Purifier 131s": [ - ("post", "/131airPurifier/v1/device/deviceDetail", "purifier-detail.json") + ( + "post", + "/131airPurifier/v1/device/deviceDetail", + "air-purifier-131s-detail.json", + ) ], "Air Purifier 200s": [ ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") diff --git a/tests/components/vesync/fixtures/air-purifier-131s-detail.json b/tests/components/vesync/fixtures/air-purifier-131s-detail.json new file mode 100644 index 00000000000..a7598c621d3 --- /dev/null +++ b/tests/components/vesync/fixtures/air-purifier-131s-detail.json @@ -0,0 +1,25 @@ +{ + "code": 0, + "msg": "request success", + "traceId": "1744558015", + "screenStatus": "on", + "filterLife": { + "change": false, + "useHour": 3034, + "percent": 25 + }, + "activeTime": 0, + "timer": null, + "scheduleCount": 0, + "schedule": null, + "levelNew": 0, + "airQuality": "excellent", + "level": null, + "mode": "sleep", + "deviceName": "Levoit 131S Air Purifier", + "currentFirmVersion": "2.0.58", + "childLock": "off", + "deviceStatus": "on", + "deviceImg": "https://image.vesync.com/defaultImages/deviceDefaultImages/airpurifier131_240.png", + "connectionStatus": "online" +} diff --git a/tests/components/vesync/fixtures/purifier-detail.json b/tests/components/vesync/fixtures/purifier-detail.json deleted file mode 100644 index de0843975c3..00000000000 --- a/tests/components/vesync/fixtures/purifier-detail.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "code": 0, - "deviceStatus": "on", - "activeTime": 50, - "filterLife": 90, - "screenStatus": "on", - "mode": "auto", - "level": 2, - "airQuality": 95 -} diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 407e18d65b6..aa55a9be3cb 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -290,6 +290,30 @@ }), 'unit_of_measurement': '%', }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fan_display', + 'icon': None, + 'name': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Display', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Fan Display', + }), + 'entity_id': 'switch.fan_display', + 'last_changed': str, + 'last_reported': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': None, + }), ]), 'name': 'Fan', 'name_by_user': None, diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 0b56a08eeff..92473647a39 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -78,11 +78,17 @@ # name: test_fan_state[Air Purifier 131s][fan.air_purifier_131s] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'active_time': 0, 'friendly_name': 'Air Purifier 131s', + 'mode': 'sleep', + 'percentage': None, + 'percentage_step': 33.333333333333336, + 'preset_mode': 'sleep', 'preset_modes': list([ 'auto', 'sleep', ]), + 'screen_status': 'on', 'supported_features': , }), 'context': , @@ -90,7 +96,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'on', }) # --- # name: test_fan_state[Air Purifier 200s][devices] diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index c701fa8a324..ecae8fa7674 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -114,7 +114,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'excellent', }) # --- # name: test_sensor_state[Air Purifier 131s][sensor.air_purifier_131s_filter_lifetime] @@ -129,7 +129,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '25', }) # --- # name: test_sensor_state[Air Purifier 200s][devices] diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index 1faed941338..f25aaf3d51b 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -36,8 +36,53 @@ # --- # name: test_switch_state[Air Purifier 131s][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.air_purifier_131s_display', + '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': 'Display', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': 'air-purifier-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Air Purifier 131s][switch.air_purifier_131s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 131s Display', + }), + 'context': , + 'entity_id': 'switch.air_purifier_131s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Air Purifier 200s][devices] list([ DeviceRegistryEntrySnapshot({ @@ -75,8 +120,53 @@ # --- # name: test_switch_state[Air Purifier 200s][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.air_purifier_200s_display', + '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': 'Display', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Air Purifier 200s][switch.air_purifier_200s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 200s Display', + }), + 'context': , + 'entity_id': 'switch.air_purifier_200s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Air Purifier 400s][devices] list([ DeviceRegistryEntrySnapshot({ @@ -114,8 +204,53 @@ # --- # name: test_switch_state[Air Purifier 400s][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.air_purifier_400s_display', + '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': 'Display', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': '400s-purifier-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Air Purifier 400s][switch.air_purifier_400s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 400s Display', + }), + 'context': , + 'entity_id': 'switch.air_purifier_400s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Air Purifier 600s][devices] list([ DeviceRegistryEntrySnapshot({ @@ -153,8 +288,53 @@ # --- # name: test_switch_state[Air Purifier 600s][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.air_purifier_600s_display', + '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': 'Display', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': '600s-purifier-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Air Purifier 600s][switch.air_purifier_600s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 600s Display', + }), + 'context': , + 'entity_id': 'switch.air_purifier_600s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Dimmable Light][devices] list([ DeviceRegistryEntrySnapshot({ @@ -270,8 +450,53 @@ # --- # name: test_switch_state[Humidifier 200s][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.humidifier_200s_display', + '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': 'Display', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': '200s-humidifier4321-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Humidifier 200s][switch.humidifier_200s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Humidifier 200s Display', + }), + 'context': , + 'entity_id': 'switch.humidifier_200s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Humidifier 600S][devices] list([ DeviceRegistryEntrySnapshot({ @@ -309,8 +534,53 @@ # --- # name: test_switch_state[Humidifier 600S][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.humidifier_600s_display', + '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': 'Display', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': '600s-humidifier-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Humidifier 600S][switch.humidifier_600s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Humidifier 600S Display', + }), + 'context': , + 'entity_id': 'switch.humidifier_600s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Outlet][devices] list([ DeviceRegistryEntrySnapshot({ @@ -433,8 +703,53 @@ # --- # name: test_switch_state[SmartTowerFan][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.smarttowerfan_display', + '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': 'Display', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': 'smarttowerfan-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[SmartTowerFan][switch.smarttowerfan_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SmartTowerFan Display', + }), + 'context': , + 'entity_id': 'switch.smarttowerfan_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switch_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index 31df2418b3d..d1e76174ea0 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -163,11 +163,11 @@ async def test_migrate_config_entry( assert migrated_humidifer is not None assert migrated_humidifer.unique_id == "humidifer" - # Assert that only one entity exists in the switch domain + # Assert that entity exists in the switch domain switch_entities = [ e for e in entity_registry.entities.values() if e.domain == "switch" ] - assert len(switch_entities) == 1 + assert len(switch_entities) == 2 humidifer_entities = [ e for e in entity_registry.entities.values() if e.domain == "humidifer" diff --git a/tests/components/vesync/test_switch.py b/tests/components/vesync/test_switch.py index 111f2b80960..e5d5986b364 100644 --- a/tests/components/vesync/test_switch.py +++ b/tests/components/vesync/test_switch.py @@ -1,17 +1,24 @@ """Tests for the switch module.""" +from contextlib import nullcontext +from unittest.mock import patch + import pytest import requests_mock from syrupy import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, 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 .common import ALL_DEVICE_NAMES, mock_devices_response +from .common import ALL_DEVICE_NAMES, ENTITY_SWITCH_DISPLAY, mock_devices_response from tests.common import MockConfigEntry +NoException = nullcontext() + @pytest.mark.parametrize("device_name", ALL_DEVICE_NAMES) async def test_switch_state( @@ -49,3 +56,72 @@ async def test_switch_state( # Check states for entity in entities: assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) + + +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_on_display"), + (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_off_display"), + ], +) +async def test_turn_on_off_display_success( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + action: str, + command: str, +) -> None: + """Test switch turn on and off command with success response.""" + + with ( + patch( + command, + return_value=True, + ) as method_mock, + patch( + "homeassistant.components.vesync.switch.VeSyncSwitchEntity.schedule_update_ha_state" + ) as update_mock, + ): + await hass.services.async_call( + SWITCH_DOMAIN, + action, + {ATTR_ENTITY_ID: ENTITY_SWITCH_DISPLAY}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + update_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_on_display"), + (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_off_display"), + ], +) +async def test_turn_on_off_display_raises_error( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + action: str, + command: str, +) -> None: + """Test switch turn on and off command raises HomeAssistantError.""" + + with ( + patch( + command, + return_value=False, + ) as method_mock, + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + SWITCH_DOMAIN, + action, + {ATTR_ENTITY_ID: ENTITY_SWITCH_DISPLAY}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 0648987eb27..7ab56f2e967 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -246,12 +246,14 @@ async def test_reconfigure_successful( # original entry assert mock_config_entry.data["host"] == "fake_host" + new_host = "192.168.100.60" + reconfigure_result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "host": "192.168.100.60", - "password": "fake_password", - "username": "fake_username", + user_input={ + CONF_HOST: new_host, + CONF_PASSWORD: "fake_password", + CONF_USERNAME: "fake_username", }, ) @@ -259,7 +261,7 @@ async def test_reconfigure_successful( assert reconfigure_result["reason"] == "reconfigure_successful" # changed entry - assert mock_config_entry.data["host"] == "192.168.100.60" + assert mock_config_entry.data["host"] == new_host @pytest.mark.parametrize( @@ -290,10 +292,10 @@ async def test_reconfigure_fails( reconfigure_result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "host": "192.168.100.60", - "password": "fake_password", - "username": "fake_username", + user_input={ + CONF_HOST: "192.168.100.60", + CONF_PASSWORD: "fake_password", + CONF_USERNAME: "fake_username", }, ) diff --git a/tests/components/vodafone_station/test_coordinator.py b/tests/components/vodafone_station/test_coordinator.py index 1a9470245c7..5f75b538803 100644 --- a/tests/components/vodafone_station/test_coordinator.py +++ b/tests/components/vodafone_station/test_coordinator.py @@ -40,8 +40,7 @@ async def test_coordinator_device_cleanup( device_tracker = f"device_tracker.{DEVICE_1_HOST}" - state = hass.states.get(device_tracker) - assert state is not None + assert hass.states.get(device_tracker) mock_vodafone_station_router.get_devices_data.return_value = { DEVICE_2_MAC: VodafoneStationDevice( @@ -59,10 +58,10 @@ async def test_coordinator_device_cleanup( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(device_tracker) - assert state is None + assert hass.states.get(device_tracker) is None assert f"Skipping entity {DEVICE_2_HOST}" in caplog.text - device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_1_MAC)}) - assert device is None + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_1_MAC)}) is None + ) assert f"Removing device: {DEVICE_1_HOST}" in caplog.text diff --git a/tests/components/vodafone_station/test_device_tracker.py b/tests/components/vodafone_station/test_device_tracker.py index e172fa76de5..a94f4ad05c4 100644 --- a/tests/components/vodafone_station/test_device_tracker.py +++ b/tests/components/vodafone_station/test_device_tracker.py @@ -47,8 +47,7 @@ async def test_consider_home( device_tracker = f"device_tracker.{DEVICE_1_HOST}" - state = hass.states.get(device_tracker) - assert state + assert (state := hass.states.get(device_tracker)) assert state.state == STATE_HOME mock_vodafone_station_router.get_devices_data.return_value[ @@ -59,6 +58,5 @@ async def test_consider_home( 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 := hass.states.get(device_tracker)) assert state.state == STATE_NOT_HOME diff --git a/tests/components/vodafone_station/test_sensor.py b/tests/components/vodafone_station/test_sensor.py index ddf97824c75..5f27b67e3dd 100644 --- a/tests/components/vodafone_station/test_sensor.py +++ b/tests/components/vodafone_station/test_sensor.py @@ -55,8 +55,7 @@ async def test_active_connection_type( active_connection_entity = "sensor.vodafone_station_m123456789_active_connection" - state = hass.states.get(active_connection_entity) - assert state + assert (state := hass.states.get(active_connection_entity)) assert state.state == STATE_UNKNOWN mock_vodafone_station_router.get_sensor_data.return_value[connection_type] = ( @@ -67,8 +66,7 @@ async def test_active_connection_type( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(active_connection_entity) - assert state + assert (state := hass.states.get(active_connection_entity)) assert state.state == LINE_TYPES[index] @@ -85,8 +83,7 @@ async def test_uptime( uptime = "2024-11-19T20:19:00+00:00" uptime_entity = "sensor.vodafone_station_m123456789_uptime" - state = hass.states.get(uptime_entity) - assert state + assert (state := hass.states.get(uptime_entity)) assert state.state == uptime mock_vodafone_station_router.get_sensor_data.return_value["sys_uptime"] = "12:17:23" @@ -95,8 +92,7 @@ async def test_uptime( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(uptime_entity) - assert state + assert (state := hass.states.get(uptime_entity)) assert state.state == uptime @@ -124,6 +120,5 @@ async def test_coordinator_client_connector_error( 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 := hass.states.get("sensor.vodafone_station_m123456789_uptime")) assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 459ab020336..65567c8e1d1 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -38,12 +38,12 @@ def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> None: """Mock the TTS cache dir with empty dir.""" -def _empty_wav() -> bytes: +def _empty_wav(framerate=16000) -> bytes: """Return bytes of an empty WAV file.""" with io.BytesIO() as wav_io: wav_file: wave.Wave_write = wave.open(wav_io, "wb") with wav_file: - wav_file.setframerate(16000) + wav_file.setframerate(framerate) wav_file.setsampwidth(2) wav_file.setnchannels(1) @@ -126,7 +126,7 @@ async def test_calls_not_allowed( await done.wait() assert sum(played_audio_bytes) > 0 - assert played_audio_bytes == snapshot() + assert played_audio_bytes == snapshot async def test_pipeline_not_found( @@ -307,10 +307,11 @@ async def test_pipeline( assert satellite.state == AssistSatelliteState.RESPONDING # Proceed with media output + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) event_callback( assist_pipeline.PipelineEvent( type=assist_pipeline.PipelineEventType.TTS_END, - data={"tts_output": {"media_id": _MEDIA_ID}}, + data={"tts_output": {"token": mock_tts_result_stream.token}}, ) ) @@ -326,28 +327,16 @@ async def test_pipeline( original_tts_response_finished() done.set() - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - assert media_source_id == _MEDIA_ID - return ("wav", _empty_wav()) - with ( patch( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.voip.assist_satellite.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), patch.object(satellite, "tts_response_finished", tts_response_finished), ): satellite._tones = Tones(0) - satellite.transport = Mock() + satellite.connection_made(Mock()) - satellite.connection_made(satellite.transport) assert satellite.state == AssistSatelliteState.IDLE # Ensure audio queue is cleared before pipeline starts @@ -457,10 +446,11 @@ async def test_tts_timeout( ) # Proceed with media output + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) event_callback( assist_pipeline.PipelineEvent( type=assist_pipeline.PipelineEventType.TTS_END, - data={"tts_output": {"media_id": _MEDIA_ID}}, + data={"tts_output": {"token": mock_tts_result_stream.token}}, ) ) @@ -474,28 +464,15 @@ async def test_tts_timeout( # Block here to force a timeout in _send_tts await asyncio.sleep(2) - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - # Should time out immediately - return ("wav", _empty_wav()) - - with ( - patch( - "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), - patch( - "homeassistant.components.voip.assist_satellite.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, ): satellite._tts_extra_timeout = 0.001 for tone in Tones: satellite._tone_bytes[tone] = tone_bytes - satellite.transport = Mock() + satellite.connection_made(Mock()) satellite.send_audio = Mock() original_send_tts = satellite._send_tts @@ -533,6 +510,7 @@ async def test_tts_wrong_extension( assert await async_setup_component(hass, "voip", {}) satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + satellite.addr = ("192.168.1.1", 12345) assert isinstance(satellite, VoipAssistSatellite) done = asyncio.Event() @@ -568,32 +546,19 @@ async def test_tts_wrong_extension( ) # Proceed with media output + # Should fail because it's not "wav" + mock_tts_result_stream = MockResultStream(hass, "mp3", b"") event_callback( assist_pipeline.PipelineEvent( type=assist_pipeline.PipelineEventType.TTS_END, - data={"tts_output": {"media_id": _MEDIA_ID}}, + data={"tts_output": {"token": mock_tts_result_stream.token}}, ) ) - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - # Should fail because it's not "wav" - return ("mp3", b"") - - with ( - patch( - "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), - patch( - "homeassistant.components.voip.assist_satellite.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, ): - satellite.transport = Mock() - original_send_tts = satellite._send_tts async def send_tts(*args, **kwargs): @@ -605,6 +570,8 @@ async def test_tts_wrong_extension( satellite._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] + satellite.connection_made(Mock()) + # silence satellite.on_chunk(bytes(_ONE_SECOND)) @@ -612,10 +579,18 @@ async def test_tts_wrong_extension( satellite.on_chunk(bytes([255] * _ONE_SECOND * 2)) # silence (assumes relaxed VAD sensitivity) - satellite.on_chunk(bytes(_ONE_SECOND * 4)) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to exhaust the audio stream - async with asyncio.timeout(1): + async with asyncio.timeout(3): await done.wait() @@ -628,6 +603,7 @@ async def test_tts_wrong_wav_format( assert await async_setup_component(hass, "voip", {}) satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + satellite.addr = ("192.168.1.1", 12345) assert isinstance(satellite, VoipAssistSatellite) done = asyncio.Event() @@ -663,39 +639,19 @@ async def test_tts_wrong_wav_format( ) # Proceed with media output + # Should fail because it's not 16Khz + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav(22050)) event_callback( assist_pipeline.PipelineEvent( type=assist_pipeline.PipelineEventType.TTS_END, - data={"tts_output": {"media_id": _MEDIA_ID}}, + data={"tts_output": {"token": mock_tts_result_stream.token}}, ) ) - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - # Should fail because it's not 16Khz, 16-bit mono - with io.BytesIO() as wav_io: - wav_file: wave.Wave_write = wave.open(wav_io, "wb") - with wav_file: - wav_file.setframerate(22050) - wav_file.setsampwidth(2) - wav_file.setnchannels(2) - - return ("wav", wav_io.getvalue()) - - with ( - patch( - "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), - patch( - "homeassistant.components.voip.assist_satellite.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, ): - satellite.transport = Mock() - original_send_tts = satellite._send_tts async def send_tts(*args, **kwargs): @@ -707,6 +663,8 @@ async def test_tts_wrong_wav_format( satellite._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] + satellite.connection_made(Mock()) + # silence satellite.on_chunk(bytes(_ONE_SECOND)) @@ -714,10 +672,18 @@ async def test_tts_wrong_wav_format( satellite.on_chunk(bytes([255] * _ONE_SECOND * 2)) # silence (assumes relaxed VAD sensitivity) - satellite.on_chunk(bytes(_ONE_SECOND * 4)) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to exhaust the audio stream - async with asyncio.timeout(1): + async with asyncio.timeout(3): await done.wait() @@ -730,6 +696,7 @@ async def test_empty_tts_output( assert await async_setup_component(hass, "voip", {}) satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + satellite.addr = ("192.168.1.1", 12345) assert isinstance(satellite, VoipAssistSatellite) async def async_pipeline_from_audio_stream(*args, **kwargs): @@ -779,7 +746,7 @@ async def test_empty_tts_output( "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", ) as mock_send_tts, ): - satellite.transport = Mock() + satellite.connection_made(Mock()) # silence satellite.on_chunk(bytes(_ONE_SECOND)) @@ -788,10 +755,18 @@ async def test_empty_tts_output( satellite.on_chunk(bytes([255] * _ONE_SECOND * 2)) # silence (assumes relaxed VAD sensitivity) - satellite.on_chunk(bytes(_ONE_SECOND * 4)) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to finish - async with asyncio.timeout(1): + async with asyncio.timeout(2): await satellite._tts_done.wait() mock_send_tts.assert_not_called() @@ -836,7 +811,7 @@ async def test_pipeline_error( ), ): satellite._tones = Tones.ERROR - satellite.transport = Mock() + satellite.connection_made(Mock()) satellite._async_send_audio = AsyncMock(side_effect=async_send_audio) # type: ignore[method-assign] satellite.on_chunk(bytes(_ONE_SECOND)) @@ -846,7 +821,7 @@ async def test_pipeline_error( await done.wait() assert sum(played_audio_bytes) > 0 - assert played_audio_bytes == snapshot() + assert played_audio_bytes == snapshot @pytest.mark.usefixtures("socket_enabled") @@ -878,10 +853,11 @@ async def test_announce( assert err.value.translation_domain == "voip" assert err.value.translation_key == "non_tts_announcement" + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, - tts_token="test-token", + tts_token=mock_tts_result_stream.token, original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -895,19 +871,25 @@ async def test_announce( "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", ) as mock_send_tts, ): - satellite.transport = Mock() announce_task = hass.async_create_background_task( satellite.async_announce(announcement), "voip_announce" ) await asyncio.sleep(0) + satellite.connection_made(Mock()) mock_protocol.outgoing_call.assert_called_once() # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - async with asyncio.timeout(1): + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(2): await announce_task - mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) + mock_send_tts.assert_called_once_with( + mock_tts_result_stream, wait_for_tone=False + ) @pytest.mark.usefixtures("socket_enabled") @@ -926,10 +908,11 @@ async def test_voip_id_is_ip_address( & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE ) + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, - tts_token="test-token", + tts_token=mock_tts_result_stream.token, original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -944,11 +927,11 @@ async def test_voip_id_is_ip_address( "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", ) as mock_send_tts, ): - satellite.transport = Mock() announce_task = hass.async_create_background_task( satellite.async_announce(announcement), "voip_announce" ) await asyncio.sleep(0) + satellite.connection_made(Mock()) mock_protocol.outgoing_call.assert_called_once() assert ( mock_protocol.outgoing_call.call_args.kwargs["destination"].host @@ -957,10 +940,16 @@ async def test_voip_id_is_ip_address( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - async with asyncio.timeout(1): + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(2): await announce_task - mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) + mock_send_tts.assert_called_once_with( + mock_tts_result_stream, wait_for_tone=False + ) @pytest.mark.usefixtures("socket_enabled") @@ -979,10 +968,11 @@ async def test_announce_timeout( & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE ) + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, - tts_token="test-token", + tts_token=mock_tts_result_stream.token, original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -999,7 +989,7 @@ async def test_announce_timeout( 0.01, ), ): - satellite.transport = Mock() + satellite.connection_made(Mock()) with pytest.raises(TimeoutError): await satellite.async_announce(announcement) @@ -1020,10 +1010,11 @@ async def test_start_conversation( & assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION ) + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, - tts_token="test-token", + tts_token=mock_tts_result_stream.token, original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -1061,10 +1052,11 @@ async def test_start_conversation( ) # Proceed with media output + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) event_callback( assist_pipeline.PipelineEvent( type=assist_pipeline.PipelineEventType.TTS_END, - data={"tts_output": {"media_id": _MEDIA_ID}}, + data={"tts_output": {"token": mock_tts_result_stream.token}}, ) ) @@ -1084,7 +1076,7 @@ async def test_start_conversation( new=async_pipeline_from_audio_stream, ), ): - satellite.transport = Mock() + satellite.connection_made(Mock()) conversation_task = hass.async_create_background_task( satellite.async_start_conversation(announcement), "voip_start_conversation" ) @@ -1093,16 +1085,20 @@ async def test_start_conversation( # Trigger announcement and wait for it to finish satellite.on_chunk(bytes(_ONE_SECOND)) - async with asyncio.timeout(1): + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(2): await tts_sent.wait() - tts_sent.clear() - # Trigger pipeline satellite.on_chunk(bytes(_ONE_SECOND)) - async with asyncio.timeout(1): - # Wait for TTS - await tts_sent.wait() + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(3) + async with asyncio.timeout(3): + # Wait for Conversation end await conversation_task @@ -1115,21 +1111,8 @@ async def test_start_conversation_user_doesnt_pick_up( """Test start conversation when the user doesn't pick up.""" assert await async_setup_component(hass, "voip", {}) - pipeline = assist_pipeline.Pipeline( - conversation_engine="test engine", - conversation_language="en", - language="en", - name="test pipeline", - stt_engine="test stt", - stt_language="en", - tts_engine="test tts", - tts_language="en", - tts_voice=None, - wake_word_entity=None, - wake_word_id=None, - ) - satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + satellite.addr = ("192.168.1.1", 12345) assert isinstance(satellite, VoipAssistSatellite) assert ( satellite.supported_features @@ -1140,62 +1123,22 @@ async def test_start_conversation_user_doesnt_pick_up( mock_protocol: AsyncMock = hass.data[DOMAIN].protocol mock_protocol.outgoing_call = Mock() - pipeline_started = asyncio.Event() - - async def async_pipeline_from_audio_stream( - hass: HomeAssistant, - context: Context, - *args, - conversation_extra_system_prompt: str | None = None, - **kwargs, - ): - # System prompt should be not be set due to timeout (user not picking up) - assert conversation_extra_system_prompt is None - - pipeline_started.set() + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + tts_token="test-token", + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + # Very short timeout which will trigger because we don't send any audio in with ( patch( - "homeassistant.components.assist_satellite.entity.async_get_pipeline", - return_value=pipeline, - ), - patch( - "homeassistant.components.voip.assist_satellite.VoipAssistSatellite.async_start_conversation", - side_effect=TimeoutError, - ), - patch( - "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), - patch( - "homeassistant.components.tts.generate_media_source_id", - return_value="media-source://bla", - ), - patch( - "homeassistant.components.tts.async_resolve_engine", - return_value="test tts", - ), - patch( - "homeassistant.components.tts.async_create_stream", - return_value=MockResultStream(hass, "wav", b""), + "homeassistant.components.voip.assist_satellite._ANNOUNCEMENT_RING_TIMEOUT", + 0.1, ), ): - satellite.transport = Mock() + satellite.connection_made(Mock()) - # Error should clear system prompt with pytest.raises(TimeoutError): - await hass.services.async_call( - assist_satellite.DOMAIN, - "start_conversation", - { - "entity_id": satellite.entity_id, - "start_message": "test announcement", - "extra_system_prompt": "test prompt", - }, - blocking=True, - ) - - # Trigger a pipeline so we can check if the system prompt was cleared - satellite.on_chunk(bytes(_ONE_SECOND)) - async with asyncio.timeout(1): - await pipeline_started.wait() + await satellite.async_start_conversation(announcement) diff --git a/tests/components/webhook/test_init.py b/tests/components/webhook/test_init.py index 15ec1b15ee5..20fe5024962 100644 --- a/tests/components/webhook/test_init.py +++ b/tests/components/webhook/test_init.py @@ -17,10 +17,12 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture -def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> TestClient: +async def mock_client( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> TestClient: """Create http client for webhooks.""" - hass.loop.run_until_complete(async_setup_component(hass, "webhook", {})) - return hass.loop.run_until_complete(hass_client()) + await async_setup_component(hass, "webhook", {}) + return await hass_client() async def test_unregistering_webhook(hass: HomeAssistant, mock_client) -> None: diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index c0114cde42b..80e6b8be056 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -26,15 +26,17 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_state_change_event from homeassistant.loader import async_get_integration -from homeassistant.setup import async_setup_component +from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component from homeassistant.util.json import json_loads from tests.common import ( MockConfigEntry, MockEntity, MockEntityPlatform, + MockModule, MockUser, async_mock_service, + mock_integration, mock_platform, ) from tests.typing import ( @@ -106,9 +108,8 @@ async def test_fire_event( hass.bus.async_listen_once("event_type_test", event_handler) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "fire_event", "event_type": "event_type_test", "event_data": {"hello": "world"}, @@ -116,7 +117,6 @@ async def test_fire_event( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -137,16 +137,14 @@ async def test_fire_event_without_data( hass.bus.async_listen_once("event_type_test", event_handler) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "fire_event", "event_type": "event_type_test", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -162,9 +160,8 @@ async def test_call_service( """Test call service command.""" calls = async_mock_service(hass, "domain_test", "test_service") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -173,7 +170,6 @@ async def test_call_service( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -191,9 +187,8 @@ async def test_return_response_error(hass: HomeAssistant, websocket_client) -> N hass.services.async_register( "domain_test", "test_service_with_no_response", lambda x: None ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 8, "type": "call_service", "domain": "domain_test", "service": "test_service_with_no_response", @@ -203,7 +198,6 @@ async def test_return_response_error(hass: HomeAssistant, websocket_client) -> N ) msg = await websocket_client.receive_json() - assert msg["id"] == 8 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == "service_validation_error" @@ -225,9 +219,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = {"foo": "bar"} - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 4, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -237,7 +230,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 4 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["response"] == {"foo": "bar"} @@ -256,9 +248,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = None - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -267,7 +258,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] mock_call.assert_called_once_with( @@ -286,9 +276,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = None - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "call_service", "domain": "homeassistant", "service": "test_service", @@ -296,7 +285,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] mock_call.assert_called_once_with( @@ -315,9 +303,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = None - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "call_service", "domain": "homeassistant", "service": "restart", @@ -325,7 +312,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] mock_call.assert_called_once_with( @@ -346,9 +332,8 @@ async def test_call_service_target( """Test call service command with target.""" calls = async_mock_service(hass, "domain_test", "test_service") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -361,7 +346,6 @@ async def test_call_service_target( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -382,9 +366,8 @@ async def test_call_service_target_template( hass: HomeAssistant, websocket_client ) -> None: """Test call service command with target does not allow template.""" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -396,7 +379,6 @@ async def test_call_service_target_template( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT @@ -406,9 +388,8 @@ async def test_call_service_not_found( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: """Test call service command.""" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -417,7 +398,6 @@ async def test_call_service_not_found( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_NOT_FOUND @@ -440,9 +420,8 @@ async def test_call_service_child_not_found( hass.services.async_register("domain_test", "test_service", serv_handler) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -451,7 +430,6 @@ async def test_call_service_child_not_found( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_HOME_ASSISTANT_ERROR @@ -492,9 +470,8 @@ async def test_call_service_schema_validation_error( schema=service_schema, ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -502,14 +479,12 @@ async def test_call_service_schema_validation_error( } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -517,14 +492,12 @@ async def test_call_service_schema_validation_error( } ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -532,7 +505,6 @@ async def test_call_service_schema_validation_error( } ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT @@ -573,9 +545,8 @@ async def test_call_service_error( hass.services.async_register("domain_test", "unknown_error", unknown_error_call) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "ha_error", @@ -583,7 +554,6 @@ async def test_call_service_error( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "home_assistant_error" @@ -592,9 +562,8 @@ async def test_call_service_error( assert msg["error"]["translation_key"] == "custom_error" assert msg["error"]["translation_domain"] == "test" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "call_service", "domain": "domain_test", "service": "service_error", @@ -602,7 +571,6 @@ async def test_call_service_error( ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "service_validation_error" @@ -611,9 +579,8 @@ async def test_call_service_error( assert msg["error"]["translation_key"] == "custom_error" assert msg["error"]["translation_domain"] == "test" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "call_service", "domain": "domain_test", "service": "unknown_error", @@ -621,7 +588,6 @@ async def test_call_service_error( ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "unknown_error" @@ -634,12 +600,12 @@ async def test_subscribe_unsubscribe_events( """Test subscribe/unsubscribe events command.""" init_count = sum(hass.bus.async_listeners().values()) - await websocket_client.send_json( - {"id": 5, "type": "subscribe_events", "event_type": "test_event"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "test_event"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -653,7 +619,7 @@ async def test_subscribe_unsubscribe_events( async with asyncio.timeout(3): msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] @@ -661,12 +627,11 @@ async def test_subscribe_unsubscribe_events( assert event["data"] == {"hello": "world"} assert event["origin"] == "LOCAL" - await websocket_client.send_json( - {"id": 6, "type": "unsubscribe_events", "subscription": 5} + await websocket_client.send_json_auto_id( + {"type": "unsubscribe_events", "subscription": subscription} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -681,10 +646,9 @@ async def test_get_states( hass.states.async_set("greeting.hello", "world") hass.states.async_set("greeting.bye", "universe") - await websocket_client.send_json({"id": 5, "type": "get_states"}) + await websocket_client.send_json_auto_id({"type": "get_states"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -711,10 +675,9 @@ async def test_get_config( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: """Test get_config command.""" - await websocket_client.send_json({"id": 5, "type": "get_config"}) + await websocket_client.send_json_auto_id({"type": "get_config"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -737,10 +700,9 @@ async def test_get_config( async def test_ping(websocket_client: MockHAClientWebSocket) -> None: """Test get_panels command.""" - await websocket_client.send_json({"id": 5, "type": "ping"}) + await websocket_client.send_json_auto_id({"type": "ping"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == "pong" @@ -792,8 +754,8 @@ async def test_subscribe_requires_admin( ) -> None: """Test subscribing events without being admin.""" hass_admin_user.groups = [] - await websocket_client.send_json( - {"id": 5, "type": "subscribe_events", "event_type": "test_event"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "test_event"} ) msg = await websocket_client.receive_json() @@ -809,10 +771,9 @@ async def test_states_filters_visible( hass_admin_user.mock_policy({"entities": {"entity_ids": {"test.entity": True}}}) hass.states.async_set("test.entity", "hello") hass.states.async_set("test.not_visible_entity", "invisible") - await websocket_client.send_json({"id": 5, "type": "get_states"}) + await websocket_client.send_json_auto_id({"type": "get_states"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -828,13 +789,12 @@ async def test_get_states_not_allows_nan( hass.states.async_set("greeting.bad", "data", {"hello": float("NaN")}) hass.states.async_set("greeting.bye", "universe") - await websocket_client.send_json({"id": 5, "type": "get_states"}) + await websocket_client.send_json_auto_id({"type": "get_states"}) bad = dict(hass.states.get("greeting.bad").as_dict()) bad["attributes"] = dict(bad["attributes"]) bad["attributes"]["hello"] = None msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == [ @@ -852,22 +812,21 @@ async def test_subscribe_unsubscribe_events_whitelist( """Test subscribe/unsubscribe events on whitelist.""" hass_admin_user.groups = [] - await websocket_client.send_json( - {"id": 5, "type": "subscribe_events", "event_type": "not-in-whitelist"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "not-in-whitelist"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == "unauthorized" - await websocket_client.send_json( - {"id": 6, "type": "subscribe_events", "event_type": "themes_updated"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "themes_updated"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 + themes_updated_subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -876,7 +835,7 @@ async def test_subscribe_unsubscribe_events_whitelist( async with asyncio.timeout(3): msg = await websocket_client.receive_json() - assert msg["id"] == 6 + assert msg["id"] == themes_updated_subscription assert msg["type"] == "event" event = msg["event"] assert event["event_type"] == "themes_updated" @@ -892,12 +851,12 @@ async def test_subscribe_unsubscribe_events_state_changed( hass_admin_user.groups = [] hass_admin_user.mock_policy({"entities": {"entity_ids": {"light.permitted": True}}}) - await websocket_client.send_json( - {"id": 7, "type": "subscribe_events", "event_type": "state_changed"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "state_changed"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -905,7 +864,7 @@ async def test_subscribe_unsubscribe_events_state_changed( hass.states.async_set("light.permitted", "on") msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"]["event_type"] == "state_changed" assert msg["event"]["data"]["entity_id"] == "light.permitted" @@ -949,15 +908,15 @@ async def test_subscribe_entities_with_unserializable_state( } ) - await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) + await websocket_client.send_json_auto_id({"type": "subscribe_entities"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": { @@ -971,7 +930,7 @@ async def test_subscribe_entities_with_unserializable_state( } hass.states.async_set("light.permitted", "on", {"effect": "help"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -988,7 +947,7 @@ async def test_subscribe_entities_with_unserializable_state( } hass.states.async_set("light.cannot_serialize", "on", {"effect": "help"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" # Order does not matter msg["event"]["c"]["light.cannot_serialize"]["-"]["a"] = set( @@ -1022,7 +981,7 @@ async def test_subscribe_entities_with_unserializable_state( {"color": "red", "cannot_serialize": CannotSerializeMe()}, ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "result" assert msg["error"] == { "code": "unknown_error", @@ -1052,15 +1011,15 @@ async def test_subscribe_unsubscribe_entities( hass_admin_user.mock_policy({"entities": {"entity_ids": {"light.permitted": True}}}) assert not hass_admin_user.is_admin - await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) + await websocket_client.send_json_auto_id({"type": "subscribe_entities"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert isinstance(msg["event"]["a"]["light.permitted"]["c"], str) assert msg["event"] == { @@ -1083,7 +1042,7 @@ async def test_subscribe_unsubscribe_entities( hass.states.async_set("light.permitted", "on", {"effect": "help", "color": "blue"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1115,7 +1074,7 @@ async def test_subscribe_unsubscribe_entities( } msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1148,7 +1107,7 @@ async def test_subscribe_unsubscribe_entities( } msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1180,12 +1139,12 @@ async def test_subscribe_unsubscribe_entities( } msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == {"r": ["light.permitted"]} msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": { @@ -1219,17 +1178,17 @@ async def test_subscribe_unsubscribe_entities_specific_entities( } ) - await websocket_client.send_json( - {"id": 7, "type": "subscribe_entities", "entity_ids": ["light.permitted"]} + await websocket_client.send_json_auto_id( + {"type": "subscribe_entities", "entity_ids": ["light.permitted"]} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert isinstance(msg["event"]["a"]["light.permitted"]["c"], str) assert msg["event"] == { @@ -1247,7 +1206,7 @@ async def test_subscribe_unsubscribe_entities_specific_entities( hass.states.async_set("light.permitted", "on", {"color": "blue"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1271,17 +1230,17 @@ async def test_subscribe_unsubscribe_entities_with_filter( """Test subscribe/unsubscribe entities with an entity filter.""" hass.states.async_set("switch.not_included", "off") hass.states.async_set("light.include", "off") - await websocket_client.send_json( - {"id": 7, "type": "subscribe_entities", "include": {"domains": ["light"]}} + await websocket_client.send_json_auto_id( + {"type": "subscribe_entities", "include": {"domains": ["light"]}} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": { @@ -1296,7 +1255,7 @@ async def test_subscribe_unsubscribe_entities_with_filter( hass.states.async_set("switch.not_included", "on") hass.states.async_set("light.include", "on") msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1317,21 +1276,20 @@ async def test_render_template_renders_template( """Test simple template is rendered and updated.""" hass.states.async_set("light.test", "on") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": "State is: {{ states('light.test') }}", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1346,7 +1304,7 @@ async def test_render_template_renders_template( hass.states.async_set("light.test", "off") msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1364,9 +1322,8 @@ async def test_render_template_with_timeout_and_variables( hass: HomeAssistant, websocket_client ) -> None: """Test a template with a timeout and variables renders without error.""" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "timeout": 10, "variables": {"test": {"value": "hello"}}, @@ -1375,12 +1332,12 @@ async def test_render_template_with_timeout_and_variables( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1400,21 +1357,20 @@ async def test_render_template_manual_entity_ids_no_longer_needed( """Test that updates to specified entity ids cause a template rerender.""" hass.states.async_set("light.test", "on") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": "State is: {{ states('light.test') }}", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1429,7 +1385,7 @@ async def test_render_template_manual_entity_ids_no_longer_needed( hass.states.async_set("light.test", "off") msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1523,9 +1479,8 @@ async def test_render_template_with_error( ) -> None: """Test a template with an error.""" caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "report_errors": True, @@ -1534,7 +1489,6 @@ async def test_render_template_with_error( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1596,9 +1550,8 @@ async def test_render_template_with_timeout_and_error( ) -> None: """Test a template with an error with a timeout.""" caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "timeout": 5, @@ -1608,7 +1561,6 @@ async def test_render_template_with_timeout_and_error( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1666,9 +1618,8 @@ async def test_render_template_strict_with_timeout_and_error( In this test report_errors is enabled. """ caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "timeout": 5, @@ -1679,7 +1630,6 @@ async def test_render_template_strict_with_timeout_and_error( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1729,9 +1679,8 @@ async def test_render_template_strict_with_timeout_and_error_2( In this test report_errors is disabled. """ caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "timeout": 5, @@ -1741,7 +1690,6 @@ async def test_render_template_strict_with_timeout_and_error_2( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1815,9 +1763,8 @@ async def test_render_template_error_in_template_code( In this test report_errors is enabled. """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "report_errors": True, @@ -1826,7 +1773,6 @@ async def test_render_template_error_in_template_code( for expected_event in expected_events_1: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1834,7 +1780,6 @@ async def test_render_template_error_in_template_code( for expected_event in expected_events_2: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1882,13 +1827,12 @@ async def test_render_template_error_in_template_code_2( In this test report_errors is disabled. """ - await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": template} + await websocket_client.send_json_auto_id( + {"type": "render_template", "template": template} ) for expected_event in expected_events_1: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1896,7 +1840,6 @@ async def test_render_template_error_in_template_code_2( for expected_event in expected_events_2: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1924,9 +1867,8 @@ async def test_render_template_with_delayed_error( {% endif %} """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template_str, "report_errors": True, @@ -1935,7 +1877,7 @@ async def test_render_template_with_delayed_error( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -1943,7 +1885,7 @@ async def test_render_template_with_delayed_error( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1957,13 +1899,13 @@ async def test_render_template_with_delayed_error( } msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event["error"] == "'None' has no attribute 'state'" msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1994,9 +1936,8 @@ async def test_render_template_with_delayed_error_2( {% endif %} """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template_str, "report_errors": False, @@ -2005,7 +1946,7 @@ async def test_render_template_with_delayed_error_2( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2013,7 +1954,7 @@ async def test_render_template_with_delayed_error_2( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -2044,9 +1985,8 @@ async def test_render_template_with_timeout( {%- endfor %} """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "timeout": 0.000001, "template": slow_template_str, @@ -2054,7 +1994,6 @@ async def test_render_template_with_timeout( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR @@ -2066,12 +2005,11 @@ async def test_render_template_returns_with_match_all( hass: HomeAssistant, websocket_client ) -> None: """Test that a template that would match with all entities still return success.""" - await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": "State is: {{ 42 }}"} + await websocket_client.send_json_auto_id( + {"type": "render_template", "template": "State is: {{ 42 }}"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2083,10 +2021,9 @@ async def test_manifest_list( http = await async_get_integration(hass, "http") websocket_api = await async_get_integration(hass, "websocket_api") - await websocket_client.send_json({"id": 5, "type": "manifest/list"}) + await websocket_client.send_json_auto_id({"type": "manifest/list"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert sorted(msg["result"], key=lambda manifest: manifest["domain"]) == [ @@ -2101,13 +2038,12 @@ async def test_manifest_list_specific_integrations( """Test loading manifests for specific integrations.""" websocket_api = await async_get_integration(hass, "websocket_api") - await websocket_client.send_json( - {"id": 5, "type": "manifest/list", "integrations": ["hue", "websocket_api"]} + await websocket_client.send_json_auto_id( + {"type": "manifest/list", "integrations": ["hue", "websocket_api"]} ) hue = await async_get_integration(hass, "hue") msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert sorted(msg["result"], key=lambda manifest: manifest["domain"]) == [ @@ -2122,23 +2058,21 @@ async def test_manifest_get( """Test getting a manifest.""" hue = await async_get_integration(hass, "hue") - await websocket_client.send_json( - {"id": 6, "type": "manifest/get", "integration": "hue"} + await websocket_client.send_json_auto_id( + {"type": "manifest/get", "integration": "hue"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == hue.manifest # Non existing - await websocket_client.send_json( - {"id": 7, "type": "manifest/get", "integration": "non_existing"} + await websocket_client.send_json_auto_id( + {"type": "manifest/get", "integration": "non_existing"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == "not_found" @@ -2157,10 +2091,9 @@ async def test_entity_source_admin( ) # Fetch all - await websocket_client.send_json({"id": 6, "type": "entity/source"}) + await websocket_client.send_json_auto_id({"type": "entity/source"}) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == { @@ -2175,10 +2108,9 @@ async def test_entity_source_admin( ) # Fetch all - await websocket_client.send_json({"id": 10, "type": "entity/source"}) + await websocket_client.send_json_auto_id({"type": "entity/source"}) msg = await websocket_client.receive_json() - assert msg["id"] == 10 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == { @@ -2192,9 +2124,8 @@ async def test_subscribe_trigger( """Test subscribing to a trigger.""" init_count = sum(hass.bus.async_listeners().values()) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "subscribe_trigger", "trigger": {"platform": "event", "event_type": "test_event"}, "variables": {"hello": "world"}, @@ -2202,7 +2133,6 @@ async def test_subscribe_trigger( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2218,7 +2148,6 @@ async def test_subscribe_trigger( async with asyncio.timeout(3): msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == "event" assert msg["event"]["context"]["id"] == context.id assert msg["event"]["variables"]["trigger"]["platform"] == "event" @@ -2229,12 +2158,11 @@ async def test_subscribe_trigger( assert event["data"] == {"hello": "world"} assert event["origin"] == "LOCAL" - await websocket_client.send_json( - {"id": 6, "type": "unsubscribe_events", "subscription": 5} + await websocket_client.send_json_auto_id( + {"type": "unsubscribe_events", "subscription": msg["id"]} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2248,9 +2176,8 @@ async def test_test_condition( """Test testing a condition.""" hass.states.async_set("hello.world", "paulus") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "test_condition", "condition": { "condition": "state", @@ -2262,14 +2189,12 @@ async def test_test_condition( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["result"] is True - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "test_condition", "condition": { "condition": "template", @@ -2280,14 +2205,12 @@ async def test_test_condition( ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["result"] is True - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "test_condition", "condition": { "condition": "template", @@ -2298,7 +2221,6 @@ async def test_test_condition( ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["result"] is False @@ -2312,9 +2234,8 @@ async def test_execute_script( hass, "domain_test", "test_service", response={"hello": "world"} ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "execute_script", "sequence": [ { @@ -2328,14 +2249,12 @@ async def test_execute_script( ) msg_no_var = await websocket_client.receive_json() - assert msg_no_var["id"] == 5 assert msg_no_var["type"] == const.TYPE_RESULT assert msg_no_var["success"] assert msg_no_var["result"]["response"] == {"hello": "world"} - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "execute_script", "sequence": { "service": "domain_test.test_service", @@ -2346,7 +2265,6 @@ async def test_execute_script( ) msg_var = await websocket_client.receive_json() - assert msg_var["id"] == 6 assert msg_var["type"] == const.TYPE_RESULT assert msg_var["success"] @@ -2403,9 +2321,8 @@ async def test_execute_script_err_localization( hass, "domain_test", "test_service", raise_exception=raise_exception ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "execute_script", "sequence": [ { @@ -2418,7 +2335,6 @@ async def test_execute_script_err_localization( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == err_code @@ -2522,12 +2438,12 @@ async def test_subscribe_unsubscribe_bootstrap_integrations( hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe bootstrap_integrations.""" - await websocket_client.send_json( - {"id": 7, "type": "subscribe_bootstrap_integrations"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_bootstrap_integrations"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2535,7 +2451,7 @@ async def test_subscribe_unsubscribe_bootstrap_integrations( async_dispatcher_send(hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, message) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == message @@ -2553,10 +2469,9 @@ async def test_integration_setup_info( "isy994": 12.8, }, ): - await websocket_client.send_json({"id": 7, "type": "integration/setup_info"}) + await websocket_client.send_json_auto_id({"type": "integration/setup_info"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == [ @@ -2855,12 +2770,7 @@ async def test_integration_descriptions( assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) - await ws_client.send_json( - { - "id": 1, - "type": "integration/descriptions", - } - ) + await ws_client.send_json_auto_id({"type": "integration/descriptions"}) response = await ws_client.receive_json() assert response["success"] @@ -2884,31 +2794,31 @@ async def test_subscribe_entities_chained_state_change( async_track_state_change_event(hass, ["light.permitted"], auto_off_listener) - await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) + await websocket_client.send_json_auto_id({"type": "subscribe_entities"}) data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == {"a": {}} hass.states.async_set("light.permitted", "on") data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": {"light.permitted": {"a": {}, "c": ANY, "lc": ANY, "s": "on"}} } data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": {"light.permitted": {"+": {"c": ANY, "lc": ANY, "s": "off"}}} @@ -2916,3 +2826,83 @@ async def test_subscribe_entities_chained_state_change( await websocket_client.close() await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("domain", "result"), + [ + ("config", {"integration_loaded": True}), + ("non_existing_domain", {"integration_loaded": False}), + ], +) +async def test_wait_integration( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + domain: str, + result: dict[str, Any], +) -> None: + """Test we can get wait for an integration to load.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "integration/wait", "domain": domain}) + response = await ws_client.receive_json() + assert response == { + "id": ANY, + "result": result, + "success": True, + "type": "result", + } + + +async def test_wait_integration_startup( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test we can get wait for an integration to load during startup.""" + ws_client = await hass_ws_client(hass) + + setup_stall = asyncio.Event() + setup_started = asyncio.Event() + + async def mock_setup(hass: HomeAssistant, _) -> bool: + setup_started.set() + await setup_stall.wait() + return True + + mock_integration(hass, MockModule("test", async_setup=mock_setup)) + + # The integration is not loaded, and is also not scheduled to load + await ws_client.send_json_auto_id({"type": "integration/wait", "domain": "test"}) + response = await ws_client.receive_json() + assert response == { + "id": ANY, + "result": {"integration_loaded": False}, + "success": True, + "type": "result", + } + + # Mark the component as scheduled to be loaded + async_set_domains_to_be_loaded(hass, {"test"}) + + # Start loading the component, including its config entries + hass.async_create_task(async_setup_component(hass, "test", {})) + await setup_started.wait() + + # The component is not yet loaded + assert "test" not in hass.config.components + + # Allow setup to proceed + setup_stall.set() + + # The component is scheduled to load, this will block until the config entry is loaded + await ws_client.send_json_auto_id({"type": "integration/wait", "domain": "test"}) + response = await ws_client.receive_json() + assert response == { + "id": ANY, + "result": {"integration_loaded": True}, + "success": True, + "type": "result", + } + + # The component has been loaded + assert "test" in hass.config.components diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index 370aab1067a..b4b11d9cf02 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -16,9 +16,10 @@ from homeassistant.components.websocket_api import ( ) from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import HomeAssistant, callback +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 async_call_logger_set_level, async_fire_time_changed from tests.typing import MockHAClientWebSocket, WebSocketGenerator @@ -523,3 +524,28 @@ async def test_binary_message( assert "Received binary message for non-existing handler 0" in caplog.text assert "Received binary message for non-existing handler 3" in caplog.text assert "Received binary message for non-existing handler 10" in caplog.text + + +async def test_enable_disable_debug_logging( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test enabling and disabling debug logging.""" + assert await async_setup_component(hass, "logger", {"logger": {}}) + async with async_call_logger_set_level( + "homeassistant.components.websocket_api", "DEBUG", hass=hass, caplog=caplog + ): + await websocket_client.send_json({"id": 1, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 1 + assert msg["type"] == "pong" + assert 'Sending b\'{"id":1,"type":"pong"}\'' in caplog.text + async with async_call_logger_set_level( + "homeassistant.components.websocket_api", "WARNING", hass=hass, caplog=caplog + ): + await websocket_client.send_json({"id": 2, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 2 + assert msg["type"] == "pong" + assert 'Sending b\'{"id":2,"type":"pong"}\'' not in caplog.text diff --git a/tests/components/weheat/conftest.py b/tests/components/weheat/conftest.py index dbdeb0726dd..692792955fc 100644 --- a/tests/components/weheat/conftest.py +++ b/tests/components/weheat/conftest.py @@ -124,6 +124,8 @@ def mock_weheat_heat_pump_instance() -> MagicMock: mock_heat_pump_instance.energy_output = 56789 mock_heat_pump_instance.compressor_rpm = 4500 mock_heat_pump_instance.compressor_percentage = 100 + mock_heat_pump_instance.dhw_flow_volume = 1.12 + mock_heat_pump_instance.central_heating_flow_volume = 1.23 mock_heat_pump_instance.indoor_unit_water_pump_state = False mock_heat_pump_instance.indoor_unit_auxiliary_pump_state = False mock_heat_pump_instance.indoor_unit_dhw_valve_or_pump_state = None diff --git a/tests/components/weheat/snapshots/test_sensor.ambr b/tests/components/weheat/snapshots/test_sensor.ambr index 77f85224913..b968d925675 100644 --- a/tests/components/weheat/snapshots/test_sensor.ambr +++ b/tests/components/weheat/snapshots/test_sensor.ambr @@ -125,6 +125,61 @@ 'state': '33', }) # --- +# name: test_all_entities[sensor.test_model_central_heating_pump_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_central_heating_pump_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Central heating pump flow', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'central_heating_flow_volume', + 'unique_id': '0000-1111-2222-3333_central_heating_flow_volume', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_central_heating_pump_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Test Model Central heating pump flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_model_central_heating_pump_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- # name: test_all_entities[sensor.test_model_compressor_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -390,6 +445,61 @@ 'state': '88', }) # --- +# name: test_all_entities[sensor.test_model_dhw_pump_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_dhw_pump_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW pump flow', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_flow_volume', + 'unique_id': '0000-1111-2222-3333_dhw_flow_volume', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_dhw_pump_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Test Model DHW pump flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_model_dhw_pump_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.12', + }) +# --- # name: test_all_entities[sensor.test_model_dhw_top_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/weheat/test_sensor.py b/tests/components/weheat/test_sensor.py index f3eec282704..eab571b09ed 100644 --- a/tests/components/weheat/test_sensor.py +++ b/tests/components/weheat/test_sensor.py @@ -33,7 +33,7 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.parametrize(("has_dhw", "nr_of_entities"), [(False, 15), (True, 17)]) +@pytest.mark.parametrize(("has_dhw", "nr_of_entities"), [(False, 16), (True, 19)]) async def test_create_entities( hass: HomeAssistant, mock_weheat_discover: AsyncMock, diff --git a/tests/components/whirlpool/__init__.py b/tests/components/whirlpool/__init__.py index 97d9b4d61d5..7d915b91116 100644 --- a/tests/components/whirlpool/__init__.py +++ b/tests/components/whirlpool/__init__.py @@ -1,8 +1,13 @@ """Tests for the Whirlpool Sixth Sense integration.""" +from unittest.mock import MagicMock + +from syrupy import SnapshotAssertion + from homeassistant.components.whirlpool.const import CONF_BRAND, DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry from tests.common import MockConfigEntry @@ -32,3 +37,28 @@ async def init_integration_with_entry( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() return entry + + +def snapshot_whirlpool_entities( + hass: HomeAssistant, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, + platform: Platform, +) -> None: + """Snapshot Whirlpool entities.""" + entities = hass.states.async_all(platform) + for entity_state in entities: + entity_entry = entity_registry.async_get(entity_state.entity_id) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state") + + +async def trigger_attr_callback( + hass: HomeAssistant, mock_api_instance: MagicMock +) -> None: + """Simulate an update trigger from the API.""" + + for call in mock_api_instance.register_attr_callback.call_args_list: + update_ha_state_cb = call[0][0] + update_ha_state_cb() + await hass.async_block_till_done() diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index 93881d3735a..7447c1edd5a 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -1,14 +1,13 @@ """Fixtures for the Whirlpool Sixth Sense integration tests.""" from unittest import mock -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import Mock import pytest -import whirlpool -import whirlpool.aircon +from whirlpool import aircon, appliancesmanager, auth, washerdryer from whirlpool.backendselector import Brand, Region -from .const import MOCK_SAID1, MOCK_SAID2, MOCK_SAID3, MOCK_SAID4 +from .const import MOCK_SAID1, MOCK_SAID2 @pytest.fixture( @@ -37,40 +36,39 @@ def fixture_brand(request: pytest.FixtureRequest) -> tuple[str, Brand]: def fixture_mock_auth_api(): """Set up Auth fixture.""" with ( - mock.patch("homeassistant.components.whirlpool.Auth") as mock_auth, + mock.patch( + "homeassistant.components.whirlpool.Auth", spec=auth.Auth + ) as mock_auth, mock.patch( "homeassistant.components.whirlpool.config_flow.Auth", new=mock_auth ), ): - mock_auth.return_value.do_auth = AsyncMock() mock_auth.return_value.is_access_token_valid.return_value = True yield mock_auth @pytest.fixture(name="mock_appliances_manager_api", autouse=True) def fixture_mock_appliances_manager_api( - mock_aircon1_api, mock_aircon2_api, mock_sensor1_api, mock_sensor2_api + mock_aircon1_api, mock_aircon2_api, mock_washer_api, mock_dryer_api ): """Set up AppliancesManager fixture.""" with ( mock.patch( - "homeassistant.components.whirlpool.AppliancesManager" + "homeassistant.components.whirlpool.AppliancesManager", + spec=appliancesmanager.AppliancesManager, ) as mock_appliances_manager, mock.patch( "homeassistant.components.whirlpool.config_flow.AppliancesManager", new=mock_appliances_manager, ), ): - mock_appliances_manager.return_value.fetch_appliances = AsyncMock() - mock_appliances_manager.return_value.connect = AsyncMock() - mock_appliances_manager.return_value.disconnect = AsyncMock() mock_appliances_manager.return_value.aircons = [ mock_aircon1_api, mock_aircon2_api, ] mock_appliances_manager.return_value.washer_dryers = [ - mock_sensor1_api, - mock_sensor2_api, + mock_washer_api, + mock_dryer_api, ] yield mock_appliances_manager @@ -92,30 +90,21 @@ def fixture_mock_backend_selector_api(): def get_aircon_mock(said): """Get a mock of an air conditioner.""" - mock_aircon = mock.Mock(said=said) + mock_aircon = Mock(spec=aircon.Aircon, said=said) mock_aircon.name = f"Aircon {said}" - mock_aircon.register_attr_callback = MagicMock() - mock_aircon.appliance_info.data_model = "aircon_model" - mock_aircon.appliance_info.category = "aircon" - mock_aircon.appliance_info.model_number = "12345" + mock_aircon.appliance_info = Mock( + data_model="aircon_model", category="aircon", model_number="12345" + ) mock_aircon.get_online.return_value = True mock_aircon.get_power_on.return_value = True - mock_aircon.get_mode.return_value = whirlpool.aircon.Mode.Cool - mock_aircon.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Auto + mock_aircon.get_mode.return_value = aircon.Mode.Cool + mock_aircon.get_fanspeed.return_value = aircon.FanSpeed.Auto mock_aircon.get_current_temp.return_value = 15 mock_aircon.get_temp.return_value = 20 mock_aircon.get_current_humidity.return_value = 80 mock_aircon.get_humidity.return_value = 50 mock_aircon.get_h_louver_swing.return_value = True - mock_aircon.set_power_on = AsyncMock() - mock_aircon.set_mode = AsyncMock() - mock_aircon.set_temp = AsyncMock() - mock_aircon.set_humidity = AsyncMock() - mock_aircon.set_mode = AsyncMock() - mock_aircon.set_fanspeed = AsyncMock() - mock_aircon.set_h_louver_swing = AsyncMock() - return mock_aircon @@ -131,73 +120,49 @@ def fixture_mock_aircon2_api(): return get_aircon_mock(MOCK_SAID2) -@pytest.fixture(name="mock_aircon_api_instances", autouse=False) -def fixture_mock_aircon_api_instances(mock_aircon1_api, mock_aircon2_api): - """Set up air conditioner API fixture.""" - with mock.patch( - "homeassistant.components.whirlpool.climate.Aircon" - ) as mock_aircon_api: - mock_aircon_api.side_effect = [mock_aircon1_api, mock_aircon2_api] - yield mock_aircon_api - - -def side_effect_function(*args, **kwargs): - """Return correct value for attribute.""" - if args[0] == "Cavity_TimeStatusEstTimeRemaining": - return 3540 - if args[0] == "Cavity_OpStatusDoorOpen": - return "0" - if args[0] == "WashCavity_OpStatusBulkDispense1Level": - return "3" - - return None - - -def get_sensor_mock(said): - """Get a mock of a sensor.""" - mock_sensor = mock.Mock(said=said) - mock_sensor.name = f"WasherDryer {said}" - mock_sensor.register_attr_callback = MagicMock() - mock_sensor.appliance_info.data_model = "washer_dryer_model" - mock_sensor.appliance_info.category = "washer_dryer" - mock_sensor.appliance_info.model_number = "12345" - mock_sensor.get_online.return_value = True - mock_sensor.get_machine_state.return_value = ( - whirlpool.washerdryer.MachineState.Standby +@pytest.fixture +def mock_washer_api(): + """Get a mock of a washer.""" + mock_washer = Mock(spec=washerdryer.WasherDryer, said="said_washer") + mock_washer.name = "Washer" + mock_washer.appliance_info = Mock( + data_model="washer", category="washer_dryer", model_number="12345" ) - mock_sensor.get_attribute.side_effect = side_effect_function - mock_sensor.get_cycle_status_filling.return_value = False - mock_sensor.get_cycle_status_rinsing.return_value = False - mock_sensor.get_cycle_status_sensing.return_value = False - mock_sensor.get_cycle_status_soaking.return_value = False - mock_sensor.get_cycle_status_spinning.return_value = False - mock_sensor.get_cycle_status_washing.return_value = False + mock_washer.get_online.return_value = True + mock_washer.get_machine_state.return_value = ( + washerdryer.MachineState.RunningMainCycle + ) + mock_washer.get_door_open.return_value = False + mock_washer.get_dispense_1_level.return_value = 3 + mock_washer.get_time_remaining.return_value = 3540 + mock_washer.get_cycle_status_filling.return_value = False + mock_washer.get_cycle_status_rinsing.return_value = False + mock_washer.get_cycle_status_sensing.return_value = False + mock_washer.get_cycle_status_soaking.return_value = False + mock_washer.get_cycle_status_spinning.return_value = False + mock_washer.get_cycle_status_washing.return_value = False - return mock_sensor + return mock_washer -@pytest.fixture(name="mock_sensor1_api", autouse=False) -def fixture_mock_sensor1_api(): - """Set up sensor API fixture.""" - return get_sensor_mock(MOCK_SAID3) - - -@pytest.fixture(name="mock_sensor2_api", autouse=False) -def fixture_mock_sensor2_api(): - """Set up sensor API fixture.""" - return get_sensor_mock(MOCK_SAID4) - - -@pytest.fixture(name="mock_sensor_api_instances", autouse=False) -def fixture_mock_sensor_api_instances(mock_sensor1_api, mock_sensor2_api): - """Set up sensor API fixture.""" - with mock.patch( - "homeassistant.components.whirlpool.sensor.WasherDryer" - ) as mock_sensor_api: - mock_sensor_api.side_effect = [ - mock_sensor1_api, - mock_sensor2_api, - mock_sensor1_api, - mock_sensor2_api, - ] - yield mock_sensor_api +@pytest.fixture +def mock_dryer_api(): + """Get a mock of a dryer.""" + mock_dryer = mock.Mock(spec=washerdryer.WasherDryer, said="said_dryer") + mock_dryer.name = "Dryer" + mock_dryer.appliance_info = Mock( + data_model="dryer", category="washer_dryer", model_number="12345" + ) + mock_dryer.get_online.return_value = True + mock_dryer.get_machine_state.return_value = ( + washerdryer.MachineState.RunningMainCycle + ) + mock_dryer.get_door_open.return_value = False + mock_dryer.get_time_remaining.return_value = 3540 + mock_dryer.get_cycle_status_filling.return_value = False + mock_dryer.get_cycle_status_rinsing.return_value = False + mock_dryer.get_cycle_status_sensing.return_value = False + mock_dryer.get_cycle_status_soaking.return_value = False + mock_dryer.get_cycle_status_spinning.return_value = False + mock_dryer.get_cycle_status_washing.return_value = False + return mock_dryer diff --git a/tests/components/whirlpool/const.py b/tests/components/whirlpool/const.py index 04ea5c0645c..f7348ba4641 100644 --- a/tests/components/whirlpool/const.py +++ b/tests/components/whirlpool/const.py @@ -2,5 +2,3 @@ MOCK_SAID1 = "said1" MOCK_SAID2 = "said2" -MOCK_SAID3 = "said3" -MOCK_SAID4 = "said4" diff --git a/tests/components/whirlpool/snapshots/test_binary_sensor.ambr b/tests/components/whirlpool/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..1a902f806cf --- /dev/null +++ b/tests/components/whirlpool/snapshots/test_binary_sensor.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.dryer_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dryer_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': 'Door', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'said_dryer-door', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.dryer_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Dryer Door', + }), + 'context': , + 'entity_id': 'binary_sensor.dryer_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.washer_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washer_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': 'Door', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'said_washer-door', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.washer_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Washer Door', + }), + 'context': , + 'entity_id': 'binary_sensor.washer_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/whirlpool/snapshots/test_climate.ambr b/tests/components/whirlpool/snapshots/test_climate.ambr new file mode 100644 index 00000000000..2957a609fa2 --- /dev/null +++ b/tests/components/whirlpool/snapshots/test_climate.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_all_entities[climate.aircon_said1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'high', + 'medium', + 'low', + 'off', + ]), + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 16, + 'swing_modes': list([ + 'horizontal', + 'off', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.aircon_said1', + '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': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'said1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[climate.aircon_said1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 80, + 'current_temperature': 15, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'high', + 'medium', + 'low', + 'off', + ]), + 'friendly_name': 'Aircon said1', + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 16, + 'supported_features': , + 'swing_mode': 'horizontal', + 'swing_modes': list([ + 'horizontal', + 'off', + ]), + 'target_temp_step': 1, + 'temperature': 20, + }), + 'context': , + 'entity_id': 'climate.aircon_said1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_all_entities[climate.aircon_said2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'high', + 'medium', + 'low', + 'off', + ]), + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 16, + 'swing_modes': list([ + 'horizontal', + 'off', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.aircon_said2', + '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': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'said2', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[climate.aircon_said2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 80, + 'current_temperature': 15, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'high', + 'medium', + 'low', + 'off', + ]), + 'friendly_name': 'Aircon said2', + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 16, + 'supported_features': , + 'swing_mode': 'horizontal', + 'swing_modes': list([ + 'horizontal', + 'off', + ]), + 'target_temp_step': 1, + 'temperature': 20, + }), + 'context': , + 'entity_id': 'climate.aircon_said2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index 7ffae8bc808..f1eef6f7dfc 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -17,14 +17,14 @@ 'ovens': dict({ }), 'washer_dryers': dict({ - 'WasherDryer said3': dict({ + 'Dryer': dict({ 'category': 'washer_dryer', - 'data_model': 'washer_dryer_model', + 'data_model': 'dryer', 'model_number': '12345', }), - 'WasherDryer said4': dict({ + 'Washer': dict({ 'category': 'washer_dryer', - 'data_model': 'washer_dryer_model', + 'data_model': 'washer', 'model_number': '12345', }), }), diff --git a/tests/components/whirlpool/snapshots/test_sensor.ambr b/tests/components/whirlpool/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..6a0465ba8b9 --- /dev/null +++ b/tests/components/whirlpool/snapshots/test_sensor.ambr @@ -0,0 +1,372 @@ +# serializer version: 1 +# name: test_all_entities[sensor.dryer_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:progress-clock', + 'original_name': 'End time', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'end_time', + 'unique_id': 'said_dryer-timeremaining', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.dryer_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Dryer End time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.dryer_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-04T12:59:00+00:00', + }) +# --- +# name: test_all_entities[sensor.dryer_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'standby', + 'setting', + 'delay_countdown', + 'delay_paused', + 'smart_delay', + 'smart_grid_pause', + 'pause', + 'running_maincycle', + 'running_postcycle', + 'exception', + 'complete', + 'power_failure', + 'service_diagnostic_mode', + 'factory_diagnostic_mode', + 'life_test', + 'customer_focus_mode', + 'demo_mode', + 'hard_stop_or_error', + 'system_initialize', + 'cycle_filling', + 'cycle_rinsing', + 'cycle_sensing', + 'cycle_soaking', + 'cycle_spinning', + 'cycle_washing', + 'door_open', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_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': 'State', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_state', + 'unique_id': 'said_dryer-state', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.dryer_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Dryer State', + 'options': list([ + 'standby', + 'setting', + 'delay_countdown', + 'delay_paused', + 'smart_delay', + 'smart_grid_pause', + 'pause', + 'running_maincycle', + 'running_postcycle', + 'exception', + 'complete', + 'power_failure', + 'service_diagnostic_mode', + 'factory_diagnostic_mode', + 'life_test', + 'customer_focus_mode', + 'demo_mode', + 'hard_stop_or_error', + 'system_initialize', + 'cycle_filling', + 'cycle_rinsing', + 'cycle_sensing', + 'cycle_soaking', + 'cycle_spinning', + 'cycle_washing', + 'door_open', + ]), + }), + 'context': , + 'entity_id': 'sensor.dryer_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running_maincycle', + }) +# --- +# name: test_all_entities[sensor.washer_detergent_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'empty', + '25', + '50', + '100', + 'active', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_detergent_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Detergent level', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'whirlpool_tank', + 'unique_id': 'said_washer-DispenseLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.washer_detergent_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washer Detergent level', + 'options': list([ + 'empty', + '25', + '50', + '100', + 'active', + ]), + }), + 'context': , + 'entity_id': 'sensor.washer_detergent_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[sensor.washer_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:progress-clock', + 'original_name': 'End time', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'end_time', + 'unique_id': 'said_washer-timeremaining', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.washer_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Washer End time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.washer_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-04T12:59:00+00:00', + }) +# --- +# name: test_all_entities[sensor.washer_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'standby', + 'setting', + 'delay_countdown', + 'delay_paused', + 'smart_delay', + 'smart_grid_pause', + 'pause', + 'running_maincycle', + 'running_postcycle', + 'exception', + 'complete', + 'power_failure', + 'service_diagnostic_mode', + 'factory_diagnostic_mode', + 'life_test', + 'customer_focus_mode', + 'demo_mode', + 'hard_stop_or_error', + 'system_initialize', + 'cycle_filling', + 'cycle_rinsing', + 'cycle_sensing', + 'cycle_soaking', + 'cycle_spinning', + 'cycle_washing', + 'door_open', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_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': 'State', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_state', + 'unique_id': 'said_washer-state', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.washer_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washer State', + 'options': list([ + 'standby', + 'setting', + 'delay_countdown', + 'delay_paused', + 'smart_delay', + 'smart_grid_pause', + 'pause', + 'running_maincycle', + 'running_postcycle', + 'exception', + 'complete', + 'power_failure', + 'service_diagnostic_mode', + 'factory_diagnostic_mode', + 'life_test', + 'customer_focus_mode', + 'demo_mode', + 'hard_stop_or_error', + 'system_initialize', + 'cycle_filling', + 'cycle_rinsing', + 'cycle_sensing', + 'cycle_soaking', + 'cycle_spinning', + 'cycle_washing', + 'door_open', + ]), + }), + 'context': , + 'entity_id': 'sensor.washer_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running_maincycle', + }) +# --- diff --git a/tests/components/whirlpool/test_binary_sensor.py b/tests/components/whirlpool/test_binary_sensor.py new file mode 100644 index 00000000000..bdd4c05c05d --- /dev/null +++ b/tests/components/whirlpool/test_binary_sensor.py @@ -0,0 +1,55 @@ +"""Test the Whirlpool Binary Sensor domain.""" + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration, snapshot_whirlpool_entities, trigger_attr_callback + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await init_integration(hass) + snapshot_whirlpool_entities(hass, entity_registry, snapshot, Platform.BINARY_SENSOR) + + +@pytest.mark.parametrize( + ("entity_id", "mock_fixture", "mock_method"), + [ + ("binary_sensor.washer_door", "mock_washer_api", "get_door_open"), + ("binary_sensor.dryer_door", "mock_dryer_api", "get_door_open"), + ], +) +async def test_simple_binary_sensors( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + mock_method: str, + request: pytest.FixtureRequest, +) -> None: + """Test simple binary sensors states.""" + mock_instance = request.getfixturevalue(mock_fixture) + mock_method = getattr(mock_instance, mock_method) + await init_integration(hass) + + mock_method.return_value = False + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + mock_method.return_value = True + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + mock_method.return_value = None + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state.state is STATE_UNKNOWN diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index 0586d654f7f..e9fb47d1c28 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -2,22 +2,16 @@ from unittest.mock import MagicMock -from attr import dataclass import pytest +from syrupy import SnapshotAssertion import whirlpool from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, - ATTR_FAN_MODES, ATTR_HVAC_MODE, - ATTR_HVAC_MODES, - ATTR_MAX_TEMP, - ATTR_MIN_TEMP, ATTR_SWING_MODE, - ATTR_SWING_MODES, - ATTR_TARGET_TEMP_STEP, DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, FAN_HIGH, @@ -31,23 +25,33 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, SWING_HORIZONTAL, SWING_OFF, - ClimateEntityFeature, HVACMode, ) from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_UNAVAILABLE, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er -from . import init_integration +from . import init_integration, snapshot_whirlpool_entities, trigger_attr_callback + + +@pytest.fixture( + params=[ + ("climate.aircon_said1", "mock_aircon1_api"), + ("climate.aircon_said2", "mock_aircon2_api"), + ] +) +def multiple_climate_entities(request: pytest.FixtureRequest) -> tuple[str, str]: + """Fixture for multiple climate entities.""" + entity_id, mock_fixture = request.param + return entity_id, mock_fixture async def update_ac_state( @@ -56,324 +60,270 @@ async def update_ac_state( mock_aircon_api_instance: MagicMock, ): """Simulate an update trigger from the API.""" - for call in mock_aircon_api_instance.register_attr_callback.call_args_list: - update_ha_state_cb = call[0][0] - update_ha_state_cb() - await hass.async_block_till_done() + await trigger_attr_callback(hass, mock_aircon_api_instance) return hass.states.get(entity_id) -async def test_no_appliances( - hass: HomeAssistant, mock_appliances_manager_api: MagicMock -) -> None: - """Test the setup of the climate entities when there are no appliances available.""" - mock_appliances_manager_api.return_value.aircons = [] - mock_appliances_manager_api.return_value.washer_dryers = [] - await init_integration(hass) - assert len(hass.states.async_all()) == 0 - - -async def test_static_attributes( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( hass: HomeAssistant, + snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, ) -> None: - """Test static climate attributes.""" + """Test all entities.""" await init_integration(hass) - - for said in ("said1", "said2"): - entity_id = f"climate.{said}" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.unique_id == said - - state = hass.states.get(entity_id) - assert state is not None - assert state.state != STATE_UNAVAILABLE - assert state.state == HVACMode.COOL - - attributes = state.attributes - assert attributes[ATTR_FRIENDLY_NAME] == f"Aircon {said}" - - assert ( - attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.SWING_MODE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert attributes[ATTR_HVAC_MODES] == [ - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.FAN_ONLY, - HVACMode.OFF, - ] - assert attributes[ATTR_FAN_MODES] == [ - FAN_AUTO, - FAN_HIGH, - FAN_MEDIUM, - FAN_LOW, - FAN_OFF, - ] - assert attributes[ATTR_SWING_MODES] == [SWING_HORIZONTAL, SWING_OFF] - assert attributes[ATTR_TARGET_TEMP_STEP] == 1 - assert attributes[ATTR_MIN_TEMP] == 16 - assert attributes[ATTR_MAX_TEMP] == 30 + snapshot_whirlpool_entities(hass, entity_registry, snapshot, Platform.CLIMATE) async def test_dynamic_attributes( hass: HomeAssistant, - mock_aircon1_api: MagicMock, - mock_aircon2_api: MagicMock, + multiple_climate_entities: tuple[str, str], + request: pytest.FixtureRequest, ) -> None: """Test dynamic attributes.""" + entity_id, mock_fixture = multiple_climate_entities + mock_instance = request.getfixturevalue(mock_fixture) await init_integration(hass) - @dataclass - class ClimateTestInstance: - """Helper class for multiple climate and mock instances.""" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == HVACMode.COOL - entity_id: str - mock_instance: MagicMock - mock_instance_idx: int + mock_instance.get_power_on.return_value = False + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state == HVACMode.OFF - for clim_test_instance in ( - ClimateTestInstance("climate.said1", mock_aircon1_api, 0), - ClimateTestInstance("climate.said2", mock_aircon2_api, 1), - ): - entity_id = clim_test_instance.entity_id - mock_instance = clim_test_instance.mock_instance - state = hass.states.get(entity_id) - assert state is not None - assert state.state == HVACMode.COOL + mock_instance.get_online.return_value = False + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state == STATE_UNAVAILABLE - mock_instance.get_power_on.return_value = False - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.state == HVACMode.OFF + mock_instance.get_power_on.return_value = True + mock_instance.get_online.return_value = True + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state == HVACMode.COOL - mock_instance.get_online.return_value = False - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.state == STATE_UNAVAILABLE + mock_instance.get_mode.return_value = whirlpool.aircon.Mode.Heat + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state == HVACMode.HEAT - mock_instance.get_power_on.return_value = True - mock_instance.get_online.return_value = True - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.state == HVACMode.COOL + mock_instance.get_mode.return_value = whirlpool.aircon.Mode.Fan + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state == HVACMode.FAN_ONLY - mock_instance.get_mode.return_value = whirlpool.aircon.Mode.Heat - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.state == HVACMode.HEAT + mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Auto + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.attributes[ATTR_FAN_MODE] == HVACMode.AUTO - mock_instance.get_mode.return_value = whirlpool.aircon.Mode.Fan - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.state == HVACMode.FAN_ONLY + mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Low + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.attributes[ATTR_FAN_MODE] == FAN_LOW - mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Auto - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.attributes[ATTR_FAN_MODE] == HVACMode.AUTO + mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Medium + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.attributes[ATTR_FAN_MODE] == FAN_MEDIUM - mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Low - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.attributes[ATTR_FAN_MODE] == FAN_LOW + mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.High + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.attributes[ATTR_FAN_MODE] == FAN_HIGH - mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Medium - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.attributes[ATTR_FAN_MODE] == FAN_MEDIUM + mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Off + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.attributes[ATTR_FAN_MODE] == FAN_OFF - mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.High - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.attributes[ATTR_FAN_MODE] == FAN_HIGH + mock_instance.get_current_temp.return_value = 15 + mock_instance.get_temp.return_value = 20 + mock_instance.get_current_humidity.return_value = 80 + mock_instance.get_h_louver_swing.return_value = True + attributes = (await update_ac_state(hass, entity_id, mock_instance)).attributes + assert attributes[ATTR_CURRENT_TEMPERATURE] == 15 + assert attributes[ATTR_TEMPERATURE] == 20 + assert attributes[ATTR_CURRENT_HUMIDITY] == 80 + assert attributes[ATTR_SWING_MODE] == SWING_HORIZONTAL - mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Off - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.attributes[ATTR_FAN_MODE] == FAN_OFF - - mock_instance.get_current_temp.return_value = 15 - mock_instance.get_temp.return_value = 20 - mock_instance.get_current_humidity.return_value = 80 - mock_instance.get_h_louver_swing.return_value = True - attributes = (await update_ac_state(hass, entity_id, mock_instance)).attributes - assert attributes[ATTR_CURRENT_TEMPERATURE] == 15 - assert attributes[ATTR_TEMPERATURE] == 20 - assert attributes[ATTR_CURRENT_HUMIDITY] == 80 - assert attributes[ATTR_SWING_MODE] == SWING_HORIZONTAL - - mock_instance.get_current_temp.return_value = 16 - mock_instance.get_temp.return_value = 21 - mock_instance.get_current_humidity.return_value = 70 - mock_instance.get_h_louver_swing.return_value = False - attributes = (await update_ac_state(hass, entity_id, mock_instance)).attributes - assert attributes[ATTR_CURRENT_TEMPERATURE] == 16 - assert attributes[ATTR_TEMPERATURE] == 21 - assert attributes[ATTR_CURRENT_HUMIDITY] == 70 - assert attributes[ATTR_SWING_MODE] == SWING_OFF + mock_instance.get_current_temp.return_value = 16 + mock_instance.get_temp.return_value = 21 + mock_instance.get_current_humidity.return_value = 70 + mock_instance.get_h_louver_swing.return_value = False + attributes = (await update_ac_state(hass, entity_id, mock_instance)).attributes + assert attributes[ATTR_CURRENT_TEMPERATURE] == 16 + assert attributes[ATTR_TEMPERATURE] == 21 + assert attributes[ATTR_CURRENT_HUMIDITY] == 70 + assert attributes[ATTR_SWING_MODE] == SWING_OFF +@pytest.mark.parametrize( + ("service", "service_data", "expected_call", "expected_args"), + [ + (SERVICE_TURN_OFF, {}, "set_power_on", [False]), + (SERVICE_TURN_ON, {}, "set_power_on", [True]), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.COOL}, + "set_mode", + [whirlpool.aircon.Mode.Cool], + ), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.HEAT}, + "set_mode", + [whirlpool.aircon.Mode.Heat], + ), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, + "set_mode", + [whirlpool.aircon.Mode.Fan], + ), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.OFF}, + "set_power_on", + [False], + ), + (SERVICE_SET_TEMPERATURE, {ATTR_TEMPERATURE: 20}, "set_temp", [20]), + ( + SERVICE_SET_FAN_MODE, + {ATTR_FAN_MODE: FAN_AUTO}, + "set_fanspeed", + [whirlpool.aircon.FanSpeed.Auto], + ), + ( + SERVICE_SET_FAN_MODE, + {ATTR_FAN_MODE: FAN_LOW}, + "set_fanspeed", + [whirlpool.aircon.FanSpeed.Low], + ), + ( + SERVICE_SET_FAN_MODE, + {ATTR_FAN_MODE: FAN_MEDIUM}, + "set_fanspeed", + [whirlpool.aircon.FanSpeed.Medium], + ), + ( + SERVICE_SET_FAN_MODE, + {ATTR_FAN_MODE: FAN_HIGH}, + "set_fanspeed", + [whirlpool.aircon.FanSpeed.High], + ), + ( + SERVICE_SET_SWING_MODE, + {ATTR_SWING_MODE: SWING_HORIZONTAL}, + "set_h_louver_swing", + [True], + ), + ( + SERVICE_SET_SWING_MODE, + {ATTR_SWING_MODE: SWING_OFF}, + "set_h_louver_swing", + [False], + ), + ], +) async def test_service_calls( hass: HomeAssistant, - mock_aircon1_api: MagicMock, - mock_aircon2_api: MagicMock, + service: str, + service_data: dict, + expected_call: str, + expected_args: list, + multiple_climate_entities: tuple[str, str], + request: pytest.FixtureRequest, ) -> None: """Test controlling the entity through service calls.""" await init_integration(hass) + entity_id, mock_fixture = multiple_climate_entities + mock_instance = request.getfixturevalue(mock_fixture) - @dataclass - class ClimateInstancesData: - """Helper class for multiple climate and mock instances.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) + assert getattr(mock_instance, expected_call).call_count == 1 + getattr(mock_instance, expected_call).assert_called_once_with(*expected_args) - entity_id: str - mock_instance: MagicMock - for clim_test_instance in ( - ClimateInstancesData("climate.said1", mock_aircon1_api), - ClimateInstancesData("climate.said2", mock_aircon2_api), - ): - mock_instance = clim_test_instance.mock_instance - entity_id = clim_test_instance.entity_id - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - mock_instance.set_power_on.assert_called_once_with(False) - - mock_instance.set_power_on.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - mock_instance.set_power_on.assert_called_once_with(True) - - mock_instance.set_power_on.reset_mock() - mock_instance.get_power_on.return_value = False - await hass.services.async_call( - CLIMATE_DOMAIN, +@pytest.mark.parametrize( + ("service", "service_data"), + [ + ( SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.COOL}, - blocking=True, - ) - mock_instance.set_power_on.assert_called_once_with(True) - - mock_instance.set_temp.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 16}, - blocking=True, - ) - mock_instance.set_temp.assert_called_once_with(16) - - mock_instance.set_mode.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, + {ATTR_HVAC_MODE: HVACMode.COOL}, + ), + ( SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.COOL}, - blocking=True, - ) - mock_instance.set_mode.assert_called_once_with(whirlpool.aircon.Mode.Cool) - - mock_instance.set_mode.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, + {ATTR_HVAC_MODE: HVACMode.HEAT}, + ), + ( SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, - blocking=True, - ) - mock_instance.set_mode.assert_called_once_with(whirlpool.aircon.Mode.Heat) + {ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, + ), + ], +) +async def test_service_hvac_mode_turn_on( + hass: HomeAssistant, + service: str, + service_data: dict, + multiple_climate_entities: tuple[str, str], + request: pytest.FixtureRequest, +) -> None: + """Test that the HVAC mode service call turns on the entity, if it is off.""" + await init_integration(hass) + entity_id, mock_fixture = multiple_climate_entities + mock_instance = request.getfixturevalue(mock_fixture) - mock_instance.set_mode.reset_mock() - # HVACMode.DRY is not supported - with pytest.raises(ValueError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.DRY}, - blocking=True, - ) - mock_instance.set_mode.assert_not_called() + mock_instance.get_power_on.return_value = False + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) + mock_instance.set_power_on.assert_called_once_with(True) - mock_instance.set_mode.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, + # Test that set_power_on is not called if the device is already on + mock_instance.set_power_on.reset_mock() + mock_instance.get_power_on.return_value = True + + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) + mock_instance.set_power_on.assert_not_called() + + +@pytest.mark.parametrize( + ("service", "service_data", "exception"), + [ + ( SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, - blocking=True, - ) - mock_instance.set_mode.assert_called_once_with(whirlpool.aircon.Mode.Fan) - - mock_instance.set_fanspeed.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, + {ATTR_HVAC_MODE: HVACMode.DRY}, + ValueError, + ), + ( SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_AUTO}, - blocking=True, - ) - mock_instance.set_fanspeed.assert_called_once_with( - whirlpool.aircon.FanSpeed.Auto - ) + {ATTR_FAN_MODE: FAN_MIDDLE}, + ServiceValidationError, + ), + ], +) +async def test_service_unsupported( + hass: HomeAssistant, + service: str, + service_data: dict, + exception: type[Exception], + multiple_climate_entities: tuple[str, str], +) -> None: + """Test that unsupported service calls are handled properly.""" + await init_integration(hass) + entity_id, _ = multiple_climate_entities - mock_instance.set_fanspeed.reset_mock() + with pytest.raises(exception): await hass.services.async_call( CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_LOW}, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, blocking=True, ) - mock_instance.set_fanspeed.assert_called_once_with( - whirlpool.aircon.FanSpeed.Low - ) - - mock_instance.set_fanspeed.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_MEDIUM}, - blocking=True, - ) - mock_instance.set_fanspeed.assert_called_once_with( - whirlpool.aircon.FanSpeed.Medium - ) - - mock_instance.set_fanspeed.reset_mock() - # FAN_MIDDLE is not supported - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_MIDDLE}, - blocking=True, - ) - mock_instance.set_fanspeed.assert_not_called() - - mock_instance.set_fanspeed.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_HIGH}, - blocking=True, - ) - mock_instance.set_fanspeed.assert_called_once_with( - whirlpool.aircon.FanSpeed.High - ) - - mock_instance.set_h_louver_swing.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_SWING_MODE: SWING_HORIZONTAL}, - blocking=True, - ) - mock_instance.set_h_louver_swing.assert_called_with(True) - - mock_instance.set_h_louver_swing.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_SWING_MODE: SWING_OFF}, - blocking=True, - ) - mock_instance.set_h_louver_swing.assert_called_with(False) diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index e01fbc07b51..6563f88515f 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -5,10 +5,12 @@ from unittest.mock import MagicMock, patch import aiohttp import pytest from whirlpool.auth import AccountLockedError +from whirlpool.backendselector import Brand, Region from homeassistant import config_entries from homeassistant.components.whirlpool.const import CONF_BRAND, DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -20,6 +22,42 @@ CONFIG_INPUT = { } +def assert_successful_user_flow( + mock_whirlpool_setup_entry: MagicMock, + result: ConfigFlowResult, + region: str, + brand: str, +) -> None: + """Assert that the flow was successful.""" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_USERNAME: CONFIG_INPUT[CONF_USERNAME], + CONF_PASSWORD: CONFIG_INPUT[CONF_PASSWORD], + CONF_REGION: region, + CONF_BRAND: brand, + } + assert result["result"].unique_id == CONFIG_INPUT[CONF_USERNAME] + assert len(mock_whirlpool_setup_entry.mock_calls) == 1 + + +def assert_successful_reauth_flow( + mock_entry: MockConfigEntry, + result: ConfigFlowResult, + region: str, + brand: str, +) -> None: + """Assert that the reauth flow was successful.""" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_entry.data == { + CONF_USERNAME: CONFIG_INPUT[CONF_USERNAME], + CONF_PASSWORD: "new-password", + CONF_REGION: region[0], + CONF_BRAND: brand[0], + } + + @pytest.fixture(name="mock_whirlpool_setup_entry") def fixture_mock_whirlpool_setup_entry(): """Set up async_setup_entry fixture.""" @@ -30,14 +68,14 @@ def fixture_mock_whirlpool_setup_entry(): @pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") -async def test_form( +async def test_user_flow( hass: HomeAssistant, - region, - brand, + region: tuple[str, Region], + brand: tuple[str, Brand], mock_backend_selector_api: MagicMock, mock_whirlpool_setup_entry: MagicMock, ) -> None: - """Test we get the form.""" + """Test successful flow initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -45,38 +83,39 @@ async def test_form( assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-username" - assert result2["data"] == { - "username": "test-username", - "password": "test-password", - "region": region[0], - "brand": brand[0], - } - assert len(mock_whirlpool_setup_entry.mock_calls) == 1 + assert_successful_user_flow(mock_whirlpool_setup_entry, result, region[0], brand[0]) mock_backend_selector_api.assert_called_once_with(brand[1], region[1]) -async def test_form_invalid_auth( - hass: HomeAssistant, region, brand, mock_auth_api: MagicMock +async def test_user_flow_invalid_auth( + hass: HomeAssistant, + region: tuple[str, Region], + brand: tuple[str, Brand], + mock_auth_api: MagicMock, + mock_whirlpool_setup_entry: MagicMock, ) -> None: - """Test we handle invalid auth.""" + """Test invalid authentication in the flow initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) mock_auth_api.return_value.is_access_token_valid.return_value = False - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + # Test that it succeeds if the authentication is valid + mock_auth_api.return_value.is_access_token_valid.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} + ) + assert_successful_user_flow(mock_whirlpool_setup_entry, result, region[0], brand[0]) @pytest.mark.usefixtures("mock_appliances_manager_api") @@ -89,16 +128,16 @@ async def test_form_invalid_auth( (Exception, "unknown"), ], ) -async def test_form_auth_error( +async def test_user_flow_auth_error( hass: HomeAssistant, exception: Exception, expected_error: str, - region, - brand, + region: tuple[str, Region], + brand: tuple[str, Brand], mock_auth_api: MagicMock, mock_whirlpool_setup_entry: MagicMock, ) -> None: - """Test we handle cannot connect error.""" + """Test authentication exceptions in the flow initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -108,8 +147,8 @@ async def test_form_auth_error( result["flow_id"], CONFIG_INPUT | { - "region": region[0], - "brand": brand[0], + CONF_REGION: region[0], + CONF_BRAND: brand[0], }, ) assert result["type"] is FlowResultType.FORM @@ -118,27 +157,20 @@ async def test_form_auth_error( # Test that it succeeds after the error is cleared mock_auth_api.return_value.do_auth.side_effect = None result = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-username" - assert result["data"] == { - "username": "test-username", - "password": "test-password", - "region": region[0], - "brand": brand[0], - } - assert len(mock_whirlpool_setup_entry.mock_calls) == 1 + assert_successful_user_flow(mock_whirlpool_setup_entry, result, region[0], brand[0]) @pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") -async def test_form_already_configured(hass: HomeAssistant, region, brand) -> None: - """Test we handle cannot connect error.""" +async def test_already_configured( + hass: HomeAssistant, region: tuple[str, Region], brand: tuple[str, Brand] +) -> None: + """Test that configuring the integration twice with the same data fails.""" mock_entry = MockConfigEntry( domain=DOMAIN, - data=CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + data=CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]}, unique_id="test-username", ) mock_entry.add_to_hass(hass) @@ -150,22 +182,21 @@ async def test_form_already_configured(hass: HomeAssistant, region, brand) -> No assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" @pytest.mark.usefixtures("mock_auth_api") async def test_no_appliances_flow( - hass: HomeAssistant, region, brand, mock_appliances_manager_api: MagicMock + hass: HomeAssistant, + region: tuple[str, Region], + brand: tuple[str, Brand], + mock_appliances_manager_api: MagicMock, + mock_whirlpool_setup_entry: MagicMock, ) -> None: """Test we get an error with no appliances.""" result = await hass.config_entries.flow.async_init( @@ -175,25 +206,35 @@ async def test_no_appliances_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER + original_aircons = mock_appliances_manager_api.return_value.aircons mock_appliances_manager_api.return_value.aircons = [] mock_appliances_manager_api.return_value.washer_dryers = [] - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "no_appliances"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_appliances"} + + # Test that it succeeds if appliances are found + mock_appliances_manager_api.return_value.aircons = original_aircons + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} + ) + + assert_successful_user_flow(mock_whirlpool_setup_entry, result, region[0], brand[0]) @pytest.mark.usefixtures( "mock_auth_api", "mock_appliances_manager_api", "mock_whirlpool_setup_entry" ) -async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: +async def test_reauth_flow( + hass: HomeAssistant, region: tuple[str, Region], brand: tuple[str, Brand] +) -> None: """Test a successful reauth flow.""" mock_entry = MockConfigEntry( domain=DOMAIN, - data=CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + data=CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]}, unique_id="test-username", ) mock_entry.add_to_hass(hass) @@ -204,30 +245,25 @@ async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]} ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - assert mock_entry.data == { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "new-password", - "region": region[0], - "brand": brand[0], - } + assert_successful_reauth_flow(mock_entry, result, region, brand) @pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry") async def test_reauth_flow_invalid_auth( - hass: HomeAssistant, region, brand, mock_auth_api: MagicMock + hass: HomeAssistant, + region: tuple[str, Region], + brand: tuple[str, Brand], + mock_auth_api: MagicMock, ) -> None: """Test an authorization error reauth flow.""" mock_entry = MockConfigEntry( domain=DOMAIN, - data=CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + data=CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]}, unique_id="test-username", ) mock_entry.add_to_hass(hass) @@ -238,13 +274,21 @@ async def test_reauth_flow_invalid_auth( assert result["errors"] == {} mock_auth_api.return_value.is_access_token_valid.return_value = False - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + # Test that it succeeds if the credentials are valid + mock_auth_api.return_value.is_access_token_valid.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]} + ) + + assert_successful_reauth_flow(mock_entry, result, region, brand) @pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry") @@ -261,15 +305,15 @@ async def test_reauth_flow_auth_error( hass: HomeAssistant, exception: Exception, expected_error: str, - region, - brand, + region: tuple[str, Region], + brand: tuple[str, Brand], mock_auth_api: MagicMock, ) -> None: """Test a connection error reauth flow.""" mock_entry = MockConfigEntry( domain=DOMAIN, - data=CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + data=CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]}, unique_id="test-username", ) mock_entry.add_to_hass(hass) @@ -281,9 +325,16 @@ async def test_reauth_flow_auth_error( assert result["errors"] == {} mock_auth_api.return_value.do_auth.side_effect = exception - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]} ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": expected_error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + # Test that it succeeds if the exception is cleared + mock_auth_api.return_value.do_auth.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]} + ) + + assert_successful_reauth_flow(mock_entry, result, region, brand) diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index 5f04bf84b9e..d33bd8be0e1 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -75,6 +75,16 @@ async def test_setup_brand_fallback( mock_backend_selector_api.assert_called_once_with(Brand.Whirlpool, region[1]) +async def test_setup_no_appliances( + hass: HomeAssistant, mock_appliances_manager_api: MagicMock +) -> None: + """Test setup when there are no appliances available.""" + mock_appliances_manager_api.return_value.aircons = [] + mock_appliances_manager_api.return_value.washer_dryers = [] + await init_integration(hass) + assert len(hass.states.async_all()) == 0 + + async def test_setup_http_exception( hass: HomeAssistant, mock_auth_api: MagicMock, @@ -119,7 +129,7 @@ async def test_setup_fetch_appliances_failed( mock_appliances_manager_api.return_value.fetch_appliances.return_value = False entry = await init_integration(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is ConfigEntryState.SETUP_ERROR + assert entry.state is ConfigEntryState.SETUP_RETRY async def test_unload_entry(hass: HomeAssistant) -> None: diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 95fca331707..9aa88c26123 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -1,361 +1,330 @@ """Test the Whirlpool Sensor domain.""" -from datetime import UTC, datetime -from unittest.mock import MagicMock +from datetime import UTC, datetime, timedelta +from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion from whirlpool.washerdryer import MachineState from homeassistant.components.whirlpool.sensor import SCAN_INTERVAL -from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import as_timestamp, utc_from_timestamp, utcnow -from . import init_integration -from .const import MOCK_SAID3, MOCK_SAID4 +from . import init_integration, snapshot_whirlpool_entities, trigger_attr_callback from tests.common import async_fire_time_changed, mock_restore_cache_with_extra_data - -async def update_sensor_state( - hass: HomeAssistant, entity_id: str, mock_sensor_api_instance: MagicMock -) -> State: - """Simulate an update trigger from the API.""" - - for call in mock_sensor_api_instance.register_attr_callback.call_args_list: - update_ha_state_cb = call[0][0] - update_ha_state_cb() - await hass.async_block_till_done() - - return hass.states.get(entity_id) +WASHER_ENTITY_ID_BASE = "sensor.washer" +DRYER_ENTITY_ID_BASE = "sensor.dryer" -def side_effect_function_open_door(*args, **kwargs): - """Return correct value for attribute.""" - if args[0] == "Cavity_TimeStatusEstTimeRemaining": - return 3540 - - if args[0] == "Cavity_OpStatusDoorOpen": - return "1" - - if args[0] == "WashCavity_OpStatusBulkDispense1Level": - return "3" - - return None - - -async def test_dryer_sensor_values( - hass: HomeAssistant, mock_sensor2_api: MagicMock, entity_registry: er.EntityRegistry +# Freeze time for WasherDryerTimeSensor +@pytest.mark.freeze_time("2025-05-04 12:00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: - """Test the sensor value callbacks.""" - hass.set_state(CoreState.not_running) - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) - mock_restore_cache_with_extra_data( - hass, - ( - ( - State(f"sensor.washerdryer_{MOCK_SAID3}_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ( - State(f"sensor.washerdryer_{MOCK_SAID4}_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ), - ) - + """Test all entities.""" await init_integration(hass) - - entity_id = f"sensor.washerdryer_{MOCK_SAID4}_state" - mock_instance = mock_sensor2_api - entry = entity_registry.async_get(entity_id) - assert entry - state = hass.states.get(entity_id) - assert state is not None - assert state.state == "standby" - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - state_id = f"sensor.washerdryer_{MOCK_SAID3}_end_time" - state = hass.states.get(state_id) - assert state.state == thetimestamp.isoformat() - - mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle - mock_instance.get_cycle_status_filling.return_value = False - mock_instance.attr_value_to_bool.side_effect = [ - False, - False, - False, - False, - False, - False, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "running_maincycle" - - mock_instance.get_machine_state.return_value = MachineState.Complete - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "complete" - - -async def test_washer_sensor_values( - hass: HomeAssistant, mock_sensor1_api: MagicMock, entity_registry: er.EntityRegistry -) -> None: - """Test the sensor value callbacks.""" - hass.set_state(CoreState.not_running) - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) - mock_restore_cache_with_extra_data( - hass, - ( - ( - State(f"sensor.washerdryer_{MOCK_SAID3}_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ( - State(f"sensor.washerdryer_{MOCK_SAID4}_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ), - ) - - await init_integration(hass) - - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() - - entity_id = f"sensor.washerdryer_{MOCK_SAID3}_state" - mock_instance = mock_sensor1_api - entry = entity_registry.async_get(entity_id) - assert entry - state = hass.states.get(entity_id) - assert state is not None - assert state.state == "standby" - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - state_id = f"sensor.washerdryer_{MOCK_SAID3}_end_time" - state = hass.states.get(state_id) - assert state.state == thetimestamp.isoformat() - - state_id = f"sensor.washerdryer_{MOCK_SAID3}_detergent_level" - entry = entity_registry.async_get(state_id) - assert entry - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - update_entry = entity_registry.async_update_entity( - entry.entity_id, disabled_by=None - ) - await hass.async_block_till_done() - - assert update_entry != entry - assert update_entry.disabled is False - state = hass.states.get(state_id) - assert state is None - - await hass.config_entries.async_reload(entry.config_entry_id) - state = hass.states.get(state_id) - assert state is not None - assert state.state == "50" - - # Test the washer cycle states - mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle - mock_instance.get_cycle_status_filling.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - True, - False, - False, - False, - False, - False, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_filling" - - mock_instance.get_cycle_status_filling.return_value = False - mock_instance.get_cycle_status_rinsing.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - False, - True, - False, - False, - False, - False, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_rinsing" - - mock_instance.get_cycle_status_rinsing.return_value = False - mock_instance.get_cycle_status_sensing.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - False, - False, - True, - False, - False, - False, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_sensing" - - mock_instance.get_cycle_status_sensing.return_value = False - mock_instance.get_cycle_status_soaking.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - False, - False, - False, - True, - False, - False, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_soaking" - - mock_instance.get_cycle_status_soaking.return_value = False - mock_instance.get_cycle_status_spinning.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - False, - False, - False, - False, - True, - False, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_spinning" - - mock_instance.get_cycle_status_spinning.return_value = False - mock_instance.get_cycle_status_washing.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - False, - False, - False, - False, - False, - True, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_washing" - - mock_instance.get_machine_state.return_value = MachineState.Complete - mock_instance.attr_value_to_bool.side_effect = None - mock_instance.get_attribute.side_effect = side_effect_function_open_door - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "door_open" - - -async def test_restore_state(hass: HomeAssistant) -> None: - """Test sensor restore state.""" - # Home assistant is not running yet - hass.set_state(CoreState.not_running) - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) - mock_restore_cache_with_extra_data( - hass, - ( - ( - State(f"sensor.washerdryer_{MOCK_SAID3}_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ( - State(f"sensor.washerdryer_{MOCK_SAID4}_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ), - ) - - # create and add entry - await init_integration(hass) - # restore from cache - state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") - assert state.state == thetimestamp.isoformat() - state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID4}_end_time") - assert state.state == thetimestamp.isoformat() - - -async def test_no_restore_state( - hass: HomeAssistant, mock_sensor1_api: MagicMock -) -> None: - """Test sensor restore state with no restore.""" - # create and add entry - entity_id = f"sensor.washerdryer_{MOCK_SAID3}_end_time" - await init_integration(hass) - # restore from cache - state = hass.states.get(entity_id) - assert state.state == "unknown" - - mock_sensor1_api.get_machine_state.return_value = MachineState.RunningMainCycle - state = await update_sensor_state(hass, entity_id, mock_sensor1_api) - assert state.state != "unknown" + snapshot_whirlpool_entities(hass, entity_registry, snapshot, Platform.SENSOR) +@pytest.mark.parametrize( + ("entity_id", "mock_fixture"), + [ + ("sensor.washer_end_time", "mock_washer_api"), + ("sensor.dryer_end_time", "mock_dryer_api"), + ], +) @pytest.mark.freeze_time("2022-11-30 00:00:00") -async def test_callback(hass: HomeAssistant, mock_sensor1_api: MagicMock) -> None: - """Test callback timestamp callback function.""" - hass.set_state(CoreState.not_running) - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) +async def test_washer_dryer_time_sensor( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + request: pytest.FixtureRequest, + freezer: FrozenDateTimeFactory, +) -> None: + """Test Washer/Dryer end time sensors.""" + now = utcnow() + restored_datetime: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, - ( + [ ( - State(f"sensor.washerdryer_{MOCK_SAID3}_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ( - State(f"sensor.washerdryer_{MOCK_SAID4}_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ), + State(entity_id, "1"), + {"native_value": restored_datetime, "native_unit_of_measurement": None}, + ) + ], ) - # create and add entry + mock_instance = request.getfixturevalue(mock_fixture) + mock_instance.get_machine_state.return_value = MachineState.Pause await init_integration(hass) - # restore from cache - state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") - assert state.state == thetimestamp.isoformat() - callback = mock_sensor1_api.register_attr_callback.call_args_list[1][0][0] - callback() - state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") - assert state.state == thetimestamp.isoformat() - mock_sensor1_api.get_machine_state.return_value = MachineState.RunningMainCycle - mock_sensor1_api.get_attribute.side_effect = None - mock_sensor1_api.get_attribute.return_value = "60" - callback() + # Test restored state. + state = hass.states.get(entity_id) + assert state.state == restored_datetime.isoformat() - # Test new timestamp when machine starts a cycle. - state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") - time = state.state - assert state.state != thetimestamp.isoformat() + # Test no time change because the machine is not running. + await trigger_attr_callback(hass, mock_instance) - # Test no timestamp change for < 60 seconds time change. - mock_sensor1_api.get_attribute.return_value = "65" - callback() - state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") - assert state.state == time + state = hass.states.get(entity_id) + assert state.state == restored_datetime.isoformat() + + # Test new time when machine starts a cycle. + mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle + mock_instance.get_time_remaining.return_value = 60 + await trigger_attr_callback(hass, mock_instance) + + state = hass.states.get(entity_id) + expected_time = (now + timedelta(seconds=60)).isoformat() + assert state.state == expected_time + + # Test no state change for < 60 seconds elapsed time. + mock_instance.get_time_remaining.return_value = 65 + await trigger_attr_callback(hass, mock_instance) + + state = hass.states.get(entity_id) + assert state.state == expected_time # Test timestamp change for > 60 seconds. - mock_sensor1_api.get_attribute.return_value = "125" - callback() - state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") - newtime = utc_from_timestamp(as_timestamp(time) + 65) - assert state.state == newtime.isoformat() + mock_instance.get_time_remaining.return_value = 125 + await trigger_attr_callback(hass, mock_instance) + + state = hass.states.get(entity_id) + assert ( + state.state == utc_from_timestamp(as_timestamp(expected_time) + 65).isoformat() + ) + + # Test that periodic updates call the API to fetch data + mock_instance.fetch_data.reset_mock() + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_instance.fetch_data.assert_called_once() + + +@pytest.mark.parametrize( + ("entity_id", "mock_fixture"), + [ + ("sensor.washer_end_time", "mock_washer_api"), + ("sensor.dryer_end_time", "mock_dryer_api"), + ], +) +@pytest.mark.freeze_time("2022-11-30 00:00:00") +async def test_washer_dryer_time_sensor_no_restore( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + request: pytest.FixtureRequest, +) -> None: + """Test Washer/Dryer end time sensors without state restore.""" + now = utcnow() + + mock_instance = request.getfixturevalue(mock_fixture) + mock_instance.get_machine_state.return_value = MachineState.Pause + await init_integration(hass) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + + # Test no change because the machine is paused. + await trigger_attr_callback(hass, mock_instance) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + + # Test new time when machine starts a cycle. + mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle + mock_instance.get_time_remaining.return_value = 60 + await trigger_attr_callback(hass, mock_instance) + + state = hass.states.get(entity_id) + expected_time = (now + timedelta(seconds=60)).isoformat() + assert state.state == expected_time + + +@pytest.mark.parametrize( + ("entity_id", "mock_fixture"), + [ + ("sensor.washer_state", "mock_washer_api"), + ("sensor.dryer_state", "mock_dryer_api"), + ], +) +@pytest.mark.parametrize( + ("machine_state", "expected_state"), + [ + (MachineState.Standby, "standby"), + (MachineState.Setting, "setting"), + (MachineState.DelayCountdownMode, "delay_countdown"), + (MachineState.DelayPause, "delay_paused"), + (MachineState.SmartDelay, "smart_delay"), + (MachineState.SmartGridPause, "smart_grid_pause"), + (MachineState.Pause, "pause"), + (MachineState.RunningMainCycle, "running_maincycle"), + (MachineState.RunningPostCycle, "running_postcycle"), + (MachineState.Exceptions, "exception"), + (MachineState.Complete, "complete"), + (MachineState.PowerFailure, "power_failure"), + (MachineState.ServiceDiagnostic, "service_diagnostic_mode"), + (MachineState.FactoryDiagnostic, "factory_diagnostic_mode"), + (MachineState.LifeTest, "life_test"), + (MachineState.CustomerFocusMode, "customer_focus_mode"), + (MachineState.DemoMode, "demo_mode"), + (MachineState.HardStopOrError, "hard_stop_or_error"), + (MachineState.SystemInit, "system_initialize"), + ], +) +async def test_washer_dryer_machine_states( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + machine_state: MachineState, + expected_state: str, + request: pytest.FixtureRequest, +) -> None: + """Test Washer/Dryer machine states.""" + mock_instance = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + mock_instance.get_machine_state.return_value = machine_state + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("entity_id", "mock_fixture"), + [ + ("sensor.washer_state", "mock_washer_api"), + ("sensor.dryer_state", "mock_dryer_api"), + ], +) +@pytest.mark.parametrize( + ( + "filling", + "rinsing", + "sensing", + "soaking", + "spinning", + "washing", + "expected_state", + ), + [ + (True, False, False, False, False, False, "cycle_filling"), + (False, True, False, False, False, False, "cycle_rinsing"), + (False, False, True, False, False, False, "cycle_sensing"), + (False, False, False, True, False, False, "cycle_soaking"), + (False, False, False, False, True, False, "cycle_spinning"), + (False, False, False, False, False, True, "cycle_washing"), + ], +) +async def test_washer_dryer_running_states( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + filling: bool, + rinsing: bool, + sensing: bool, + soaking: bool, + spinning: bool, + washing: bool, + expected_state: str, + request: pytest.FixtureRequest, +) -> None: + """Test Washer/Dryer machine states for RunningMainCycle.""" + mock_instance = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle + mock_instance.get_cycle_status_filling.return_value = filling + mock_instance.get_cycle_status_rinsing.return_value = rinsing + mock_instance.get_cycle_status_sensing.return_value = sensing + mock_instance.get_cycle_status_soaking.return_value = soaking + mock_instance.get_cycle_status_spinning.return_value = spinning + mock_instance.get_cycle_status_washing.return_value = washing + + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("entity_id", "mock_fixture"), + [ + ("sensor.washer_state", "mock_washer_api"), + ("sensor.dryer_state", "mock_dryer_api"), + ], +) +async def test_washer_dryer_door_open_state( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + request: pytest.FixtureRequest, +) -> None: + """Test Washer/Dryer machine state when door is open.""" + mock_instance = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + state = hass.states.get(entity_id) + assert state.state == "running_maincycle" + + mock_instance.get_door_open.return_value = True + + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state.state == "door_open" + + mock_instance.get_door_open.return_value = False + + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state.state == "running_maincycle" + + +@pytest.mark.parametrize( + ("entity_id", "mock_fixture", "mock_method_name", "values"), + [ + ( + "sensor.washer_detergent_level", + "mock_washer_api", + "get_dispense_1_level", + [ + (0, STATE_UNKNOWN), + (1, "empty"), + (2, "25"), + (3, "50"), + (4, "100"), + (5, "active"), + ], + ), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_simple_enum_sensors( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + mock_method_name: str, + values: list[tuple[int, str]], + request: pytest.FixtureRequest, +) -> None: + """Test simple enum sensors where state maps directly from a single API value.""" + await init_integration(hass) + + mock_instance = request.getfixturevalue(mock_fixture) + mock_method = getattr(mock_instance, mock_method_name) + for raw_value, expected_state in values: + mock_method.return_value = raw_value + + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == expected_state diff --git a/tests/components/wmspro/conftest.py b/tests/components/wmspro/conftest.py index 4b0e7eb4fef..dc648dafcc2 100644 --- a/tests/components/wmspro/conftest.py +++ b/tests/components/wmspro/conftest.py @@ -55,17 +55,29 @@ def mock_hub_configuration_test() -> Generator[AsyncMock]: """Override WebControlPro.configuration.""" with patch( "wmspro.webcontrol.WebControlPro._getConfiguration", - return_value=load_json_object_fixture("example_config_test.json", DOMAIN), + return_value=load_json_object_fixture("config_test.json", DOMAIN), ) as mock_hub_configuration: yield mock_hub_configuration @pytest.fixture -def mock_hub_configuration_prod() -> Generator[AsyncMock]: +def mock_hub_configuration_prod_awning_dimmer() -> Generator[AsyncMock]: """Override WebControlPro._getConfiguration.""" with patch( "wmspro.webcontrol.WebControlPro._getConfiguration", - return_value=load_json_object_fixture("example_config_prod.json", DOMAIN), + return_value=load_json_object_fixture("config_prod_awning_dimmer.json", DOMAIN), + ) as mock_hub_configuration: + yield mock_hub_configuration + + +@pytest.fixture +def mock_hub_configuration_prod_roller_shutter() -> Generator[AsyncMock]: + """Override WebControlPro._getConfiguration.""" + with patch( + "wmspro.webcontrol.WebControlPro._getConfiguration", + return_value=load_json_object_fixture( + "config_prod_roller_shutter.json", DOMAIN + ), ) as mock_hub_configuration: yield mock_hub_configuration @@ -75,23 +87,31 @@ def mock_hub_status_prod_awning() -> Generator[AsyncMock]: """Override WebControlPro._getStatus.""" with patch( "wmspro.webcontrol.WebControlPro._getStatus", - return_value=load_json_object_fixture( - "example_status_prod_awning.json", DOMAIN - ), - ) as mock_dest_refresh: - yield mock_dest_refresh + return_value=load_json_object_fixture("status_prod_awning.json", DOMAIN), + ) as mock_hub_status: + yield mock_hub_status @pytest.fixture def mock_hub_status_prod_dimmer() -> Generator[AsyncMock]: + """Override WebControlPro._getStatus.""" + with patch( + "wmspro.webcontrol.WebControlPro._getStatus", + return_value=load_json_object_fixture("status_prod_dimmer.json", DOMAIN), + ) as mock_hub_status: + yield mock_hub_status + + +@pytest.fixture +def mock_hub_status_prod_roller_shutter() -> Generator[AsyncMock]: """Override WebControlPro._getStatus.""" with patch( "wmspro.webcontrol.WebControlPro._getStatus", return_value=load_json_object_fixture( - "example_status_prod_dimmer.json", DOMAIN + "status_prod_roller_shutter.json", DOMAIN ), - ) as mock_dest_refresh: - yield mock_dest_refresh + ) as mock_hub_status: + yield mock_hub_status @pytest.fixture @@ -100,8 +120,8 @@ def mock_dest_refresh() -> Generator[AsyncMock]: with patch( "wmspro.destination.Destination.refresh", return_value=True, - ) as mock_dest_refresh: - yield mock_dest_refresh + ) as mock_hub_status: + yield mock_hub_status @pytest.fixture diff --git a/tests/components/wmspro/fixtures/example_config_prod.json b/tests/components/wmspro/fixtures/config_prod_awning_dimmer.json similarity index 100% rename from tests/components/wmspro/fixtures/example_config_prod.json rename to tests/components/wmspro/fixtures/config_prod_awning_dimmer.json diff --git a/tests/components/wmspro/fixtures/config_prod_roller_shutter.json b/tests/components/wmspro/fixtures/config_prod_roller_shutter.json new file mode 100644 index 00000000000..b865c32f18a --- /dev/null +++ b/tests/components/wmspro/fixtures/config_prod_roller_shutter.json @@ -0,0 +1,171 @@ +{ + "command": "getConfiguration", + "protocolVersion": "1.0.0", + "destinations": [ + { + "id": 18894, + "animationType": 2, + "names": ["Wohnebene alle", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 4, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 116682, + "animationType": 2, + "names": ["Wohnzimmer", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 4, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 172555, + "animationType": 2, + "names": ["Badezimmer", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 4, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 230952, + "animationType": 2, + "names": ["Sportzimmer", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 4, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 284942, + "animationType": 2, + "names": ["Terrasse", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 4, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 328518, + "animationType": 2, + "names": ["alle Rolll\u00e4den", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 4, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + } + ], + "rooms": [ + { + "id": 15175, + "name": "Wohnbereich", + "destinations": [18894, 116682, 172555, 230952], + "scenes": [] + }, + { + "id": 92218, + "name": "Terrasse", + "destinations": [284942], + "scenes": [] + }, + { + "id": 193582, + "name": "Alle", + "destinations": [328518], + "scenes": [] + } + ], + "scenes": [] +} diff --git a/tests/components/wmspro/fixtures/example_config_test.json b/tests/components/wmspro/fixtures/config_test.json similarity index 100% rename from tests/components/wmspro/fixtures/example_config_test.json rename to tests/components/wmspro/fixtures/config_test.json diff --git a/tests/components/wmspro/fixtures/example_status_prod_awning.json b/tests/components/wmspro/fixtures/status_prod_awning.json similarity index 100% rename from tests/components/wmspro/fixtures/example_status_prod_awning.json rename to tests/components/wmspro/fixtures/status_prod_awning.json diff --git a/tests/components/wmspro/fixtures/example_status_prod_dimmer.json b/tests/components/wmspro/fixtures/status_prod_dimmer.json similarity index 100% rename from tests/components/wmspro/fixtures/example_status_prod_dimmer.json rename to tests/components/wmspro/fixtures/status_prod_dimmer.json diff --git a/tests/components/wmspro/fixtures/status_prod_roller_shutter.json b/tests/components/wmspro/fixtures/status_prod_roller_shutter.json new file mode 100644 index 00000000000..a409c61b1b3 --- /dev/null +++ b/tests/components/wmspro/fixtures/status_prod_roller_shutter.json @@ -0,0 +1,22 @@ +{ + "command": "getStatus", + "protocolVersion": "1.0.0", + "details": [ + { + "destinationId": 18894, + "data": { + "drivingCause": 0, + "heartbeatError": false, + "blocking": false, + "productData": [ + { + "actionId": 0, + "value": { + "percentage": 100 + } + } + ] + } + } + ] +} diff --git a/tests/components/wmspro/snapshots/test_diagnostics.ambr b/tests/components/wmspro/snapshots/test_diagnostics.ambr index 00cb62e18c4..0c5edd91315 100644 --- a/tests/components/wmspro/snapshots/test_diagnostics.ambr +++ b/tests/components/wmspro/snapshots/test_diagnostics.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_diagnostics +# name: test_diagnostics[mock_hub_configuration_prod_awning_dimmer] dict({ 'config': dict({ 'command': 'getConfiguration', @@ -242,3 +242,540 @@ }), }) # --- +# name: test_diagnostics[mock_hub_configuration_prod_roller_shutter] + dict({ + 'config': dict({ + 'command': 'getConfiguration', + 'destinations': list([ + dict({ + 'actions': list([ + dict({ + 'actionDescription': 4, + 'actionType': 0, + 'id': 0, + 'maxValue': 100, + 'minValue': 0, + }), + dict({ + 'actionDescription': 12, + 'actionType': 6, + 'id': 16, + }), + dict({ + 'actionDescription': 13, + 'actionType': 8, + 'id': 22, + }), + ]), + 'animationType': 2, + 'id': 18894, + 'names': list([ + 'Wohnebene alle', + '', + '', + '', + ]), + }), + dict({ + 'actions': list([ + dict({ + 'actionDescription': 4, + 'actionType': 0, + 'id': 0, + 'maxValue': 100, + 'minValue': 0, + }), + dict({ + 'actionDescription': 12, + 'actionType': 6, + 'id': 16, + }), + dict({ + 'actionDescription': 13, + 'actionType': 8, + 'id': 22, + }), + ]), + 'animationType': 2, + 'id': 116682, + 'names': list([ + 'Wohnzimmer', + '', + '', + '', + ]), + }), + dict({ + 'actions': list([ + dict({ + 'actionDescription': 4, + 'actionType': 0, + 'id': 0, + 'maxValue': 100, + 'minValue': 0, + }), + dict({ + 'actionDescription': 12, + 'actionType': 6, + 'id': 16, + }), + dict({ + 'actionDescription': 13, + 'actionType': 8, + 'id': 22, + }), + ]), + 'animationType': 2, + 'id': 172555, + 'names': list([ + 'Badezimmer', + '', + '', + '', + ]), + }), + dict({ + 'actions': list([ + dict({ + 'actionDescription': 4, + 'actionType': 0, + 'id': 0, + 'maxValue': 100, + 'minValue': 0, + }), + dict({ + 'actionDescription': 12, + 'actionType': 6, + 'id': 16, + }), + dict({ + 'actionDescription': 13, + 'actionType': 8, + 'id': 22, + }), + ]), + 'animationType': 2, + 'id': 230952, + 'names': list([ + 'Sportzimmer', + '', + '', + '', + ]), + }), + dict({ + 'actions': list([ + dict({ + 'actionDescription': 4, + 'actionType': 0, + 'id': 0, + 'maxValue': 100, + 'minValue': 0, + }), + dict({ + 'actionDescription': 12, + 'actionType': 6, + 'id': 16, + }), + dict({ + 'actionDescription': 13, + 'actionType': 8, + 'id': 22, + }), + ]), + 'animationType': 2, + 'id': 284942, + 'names': list([ + 'Terrasse', + '', + '', + '', + ]), + }), + dict({ + 'actions': list([ + dict({ + 'actionDescription': 4, + 'actionType': 0, + 'id': 0, + 'maxValue': 100, + 'minValue': 0, + }), + dict({ + 'actionDescription': 12, + 'actionType': 6, + 'id': 16, + }), + dict({ + 'actionDescription': 13, + 'actionType': 8, + 'id': 22, + }), + ]), + 'animationType': 2, + 'id': 328518, + 'names': list([ + 'alle Rollläden', + '', + '', + '', + ]), + }), + ]), + 'protocolVersion': '1.0.0', + 'rooms': list([ + dict({ + 'destinations': list([ + 18894, + 116682, + 172555, + 230952, + ]), + 'id': 15175, + 'name': 'Wohnbereich', + 'scenes': list([ + ]), + }), + dict({ + 'destinations': list([ + 284942, + ]), + 'id': 92218, + 'name': 'Terrasse', + 'scenes': list([ + ]), + }), + dict({ + 'destinations': list([ + 328518, + ]), + 'id': 193582, + 'name': 'Alle', + 'scenes': list([ + ]), + }), + ]), + 'scenes': list([ + ]), + }), + 'dests': dict({ + '116682': dict({ + 'actions': dict({ + '0': dict({ + 'actionDescription': 'RollerShutterBlindDrive', + 'actionType': 'Percentage', + 'attrs': dict({ + 'maxValue': 100, + 'minValue': 0, + }), + 'id': 0, + 'params': dict({ + }), + }), + '16': dict({ + 'actionDescription': 'ManualCommand', + 'actionType': 'Stop', + 'attrs': dict({ + }), + 'id': 16, + 'params': dict({ + }), + }), + '22': dict({ + 'actionDescription': 'Identify', + 'actionType': 'Identify', + 'attrs': dict({ + }), + 'id': 22, + 'params': dict({ + }), + }), + }), + 'animationType': 'RollerShutterBlind', + 'available': True, + 'blocking': None, + 'drivingCause': 'Unknown', + 'heartbeatError': None, + 'id': 116682, + 'name': 'Wohnzimmer', + 'room': dict({ + '15175': 'Wohnbereich', + }), + 'status': dict({ + }), + 'unknownProducts': dict({ + }), + }), + '172555': dict({ + 'actions': dict({ + '0': dict({ + 'actionDescription': 'RollerShutterBlindDrive', + 'actionType': 'Percentage', + 'attrs': dict({ + 'maxValue': 100, + 'minValue': 0, + }), + 'id': 0, + 'params': dict({ + }), + }), + '16': dict({ + 'actionDescription': 'ManualCommand', + 'actionType': 'Stop', + 'attrs': dict({ + }), + 'id': 16, + 'params': dict({ + }), + }), + '22': dict({ + 'actionDescription': 'Identify', + 'actionType': 'Identify', + 'attrs': dict({ + }), + 'id': 22, + 'params': dict({ + }), + }), + }), + 'animationType': 'RollerShutterBlind', + 'available': True, + 'blocking': None, + 'drivingCause': 'Unknown', + 'heartbeatError': None, + 'id': 172555, + 'name': 'Badezimmer', + 'room': dict({ + '15175': 'Wohnbereich', + }), + 'status': dict({ + }), + 'unknownProducts': dict({ + }), + }), + '18894': dict({ + 'actions': dict({ + '0': dict({ + 'actionDescription': 'RollerShutterBlindDrive', + 'actionType': 'Percentage', + 'attrs': dict({ + 'maxValue': 100, + 'minValue': 0, + }), + 'id': 0, + 'params': dict({ + }), + }), + '16': dict({ + 'actionDescription': 'ManualCommand', + 'actionType': 'Stop', + 'attrs': dict({ + }), + 'id': 16, + 'params': dict({ + }), + }), + '22': dict({ + 'actionDescription': 'Identify', + 'actionType': 'Identify', + 'attrs': dict({ + }), + 'id': 22, + 'params': dict({ + }), + }), + }), + 'animationType': 'RollerShutterBlind', + 'available': True, + 'blocking': None, + 'drivingCause': 'Unknown', + 'heartbeatError': None, + 'id': 18894, + 'name': 'Wohnebene alle', + 'room': dict({ + '15175': 'Wohnbereich', + }), + 'status': dict({ + }), + 'unknownProducts': dict({ + }), + }), + '230952': dict({ + 'actions': dict({ + '0': dict({ + 'actionDescription': 'RollerShutterBlindDrive', + 'actionType': 'Percentage', + 'attrs': dict({ + 'maxValue': 100, + 'minValue': 0, + }), + 'id': 0, + 'params': dict({ + }), + }), + '16': dict({ + 'actionDescription': 'ManualCommand', + 'actionType': 'Stop', + 'attrs': dict({ + }), + 'id': 16, + 'params': dict({ + }), + }), + '22': dict({ + 'actionDescription': 'Identify', + 'actionType': 'Identify', + 'attrs': dict({ + }), + 'id': 22, + 'params': dict({ + }), + }), + }), + 'animationType': 'RollerShutterBlind', + 'available': True, + 'blocking': None, + 'drivingCause': 'Unknown', + 'heartbeatError': None, + 'id': 230952, + 'name': 'Sportzimmer', + 'room': dict({ + '15175': 'Wohnbereich', + }), + 'status': dict({ + }), + 'unknownProducts': dict({ + }), + }), + '284942': dict({ + 'actions': dict({ + '0': dict({ + 'actionDescription': 'RollerShutterBlindDrive', + 'actionType': 'Percentage', + 'attrs': dict({ + 'maxValue': 100, + 'minValue': 0, + }), + 'id': 0, + 'params': dict({ + }), + }), + '16': dict({ + 'actionDescription': 'ManualCommand', + 'actionType': 'Stop', + 'attrs': dict({ + }), + 'id': 16, + 'params': dict({ + }), + }), + '22': dict({ + 'actionDescription': 'Identify', + 'actionType': 'Identify', + 'attrs': dict({ + }), + 'id': 22, + 'params': dict({ + }), + }), + }), + 'animationType': 'RollerShutterBlind', + 'available': True, + 'blocking': None, + 'drivingCause': 'Unknown', + 'heartbeatError': None, + 'id': 284942, + 'name': 'Terrasse', + 'room': dict({ + '92218': 'Terrasse', + }), + 'status': dict({ + }), + 'unknownProducts': dict({ + }), + }), + '328518': dict({ + 'actions': dict({ + '0': dict({ + 'actionDescription': 'RollerShutterBlindDrive', + 'actionType': 'Percentage', + 'attrs': dict({ + 'maxValue': 100, + 'minValue': 0, + }), + 'id': 0, + 'params': dict({ + }), + }), + '16': dict({ + 'actionDescription': 'ManualCommand', + 'actionType': 'Stop', + 'attrs': dict({ + }), + 'id': 16, + 'params': dict({ + }), + }), + '22': dict({ + 'actionDescription': 'Identify', + 'actionType': 'Identify', + 'attrs': dict({ + }), + 'id': 22, + 'params': dict({ + }), + }), + }), + 'animationType': 'RollerShutterBlind', + 'available': True, + 'blocking': None, + 'drivingCause': 'Unknown', + 'heartbeatError': None, + 'id': 328518, + 'name': 'alle Rollläden', + 'room': dict({ + '193582': 'Alle', + }), + 'status': dict({ + }), + 'unknownProducts': dict({ + }), + }), + }), + 'host': 'webcontrol', + 'rooms': dict({ + '15175': dict({ + 'destinations': dict({ + '116682': 'Wohnzimmer', + '172555': 'Badezimmer', + '18894': 'Wohnebene alle', + '230952': 'Sportzimmer', + }), + 'id': 15175, + 'name': 'Wohnbereich', + 'scenes': dict({ + }), + }), + '193582': dict({ + 'destinations': dict({ + '328518': 'alle Rollläden', + }), + 'id': 193582, + 'name': 'Alle', + 'scenes': dict({ + }), + }), + '92218': dict({ + 'destinations': dict({ + '284942': 'Terrasse', + }), + 'id': 92218, + 'name': 'Terrasse', + 'scenes': dict({ + }), + }), + }), + 'scenes': dict({ + }), + }) +# --- diff --git a/tests/components/wmspro/snapshots/test_init.ambr b/tests/components/wmspro/snapshots/test_init.ambr new file mode 100644 index 00000000000..147d66f2b69 --- /dev/null +++ b/tests/components/wmspro/snapshots/test_init.ambr @@ -0,0 +1,397 @@ +# serializer version: 1 +# name: test_cover_device[mock_hub_configuration_prod_awning_dimmer-mock_hub_status_prod_awning][device-19239] + DeviceRegistryEntrySnapshot({ + 'area_id': 'terrasse', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '19239', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'Room', + 'model_id': None, + 'name': 'Terrasse', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '19239', + 'suggested_area': 'Terrasse', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_awning_dimmer-mock_hub_status_prod_awning][device-58717] + DeviceRegistryEntrySnapshot({ + 'area_id': 'terrasse', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '58717', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'Awning', + 'model_id': None, + 'name': 'Markise', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '58717', + 'suggested_area': 'Terrasse', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_awning_dimmer-mock_hub_status_prod_awning][device-97358] + DeviceRegistryEntrySnapshot({ + 'area_id': 'terrasse', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '97358', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'Dimmer', + 'model_id': None, + 'name': 'Licht', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '97358', + 'suggested_area': 'Terrasse', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_awning_dimmer-mock_hub_status_prod_dimmer][device-19239] + DeviceRegistryEntrySnapshot({ + 'area_id': 'terrasse', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '19239', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'Room', + 'model_id': None, + 'name': 'Terrasse', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '19239', + 'suggested_area': 'Terrasse', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_awning_dimmer-mock_hub_status_prod_dimmer][device-58717] + DeviceRegistryEntrySnapshot({ + 'area_id': 'terrasse', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '58717', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'Awning', + 'model_id': None, + 'name': 'Markise', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '58717', + 'suggested_area': 'Terrasse', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_awning_dimmer-mock_hub_status_prod_dimmer][device-97358] + DeviceRegistryEntrySnapshot({ + 'area_id': 'terrasse', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '97358', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'Dimmer', + 'model_id': None, + 'name': 'Licht', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '97358', + 'suggested_area': 'Terrasse', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_roller_shutter-mock_hub_status_prod_roller_shutter][device-116682] + DeviceRegistryEntrySnapshot({ + 'area_id': 'wohnbereich', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '116682', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'RollerShutterBlind', + 'model_id': None, + 'name': 'Wohnzimmer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '116682', + 'suggested_area': 'Wohnbereich', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_roller_shutter-mock_hub_status_prod_roller_shutter][device-172555] + DeviceRegistryEntrySnapshot({ + 'area_id': 'wohnbereich', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '172555', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'RollerShutterBlind', + 'model_id': None, + 'name': 'Badezimmer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '172555', + 'suggested_area': 'Wohnbereich', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_roller_shutter-mock_hub_status_prod_roller_shutter][device-18894] + DeviceRegistryEntrySnapshot({ + 'area_id': 'wohnbereich', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '18894', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'RollerShutterBlind', + 'model_id': None, + 'name': 'Wohnebene alle', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '18894', + 'suggested_area': 'Wohnbereich', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_roller_shutter-mock_hub_status_prod_roller_shutter][device-230952] + DeviceRegistryEntrySnapshot({ + 'area_id': 'wohnbereich', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '230952', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'RollerShutterBlind', + 'model_id': None, + 'name': 'Sportzimmer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '230952', + 'suggested_area': 'Wohnbereich', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_roller_shutter-mock_hub_status_prod_roller_shutter][device-284942] + DeviceRegistryEntrySnapshot({ + 'area_id': 'terrasse', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '284942', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'RollerShutterBlind', + 'model_id': None, + 'name': 'Terrasse', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '284942', + 'suggested_area': 'Terrasse', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_roller_shutter-mock_hub_status_prod_roller_shutter][device-328518] + DeviceRegistryEntrySnapshot({ + 'area_id': 'alle', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '328518', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'RollerShutterBlind', + 'model_id': None, + 'name': 'alle Rollläden', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '328518', + 'suggested_area': 'Alle', + 'sw_version': None, + 'via_device_id': , + }) +# --- diff --git a/tests/components/wmspro/test_config_flow.py b/tests/components/wmspro/test_config_flow.py index 2c628bbc296..dc56d2bf988 100644 --- a/tests/components/wmspro/test_config_flow.py +++ b/tests/components/wmspro/test_config_flow.py @@ -367,13 +367,15 @@ async def test_config_flow_multiple_entries( mock_hub_ping: AsyncMock, mock_dest_refresh: AsyncMock, mock_hub_configuration_test: AsyncMock, - mock_hub_configuration_prod: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, ) -> None: """Test we allow creation of different config entries.""" await setup_config_entry(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED - mock_hub_configuration_prod.return_value = mock_hub_configuration_test.return_value + mock_hub_configuration_prod_awning_dimmer.return_value = ( + mock_hub_configuration_test.return_value + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} diff --git a/tests/components/wmspro/test_cover.py b/tests/components/wmspro/test_cover.py index 2c20ef51b64..ba2ab796c7d 100644 --- a/tests/components/wmspro/test_cover.py +++ b/tests/components/wmspro/test_cover.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy import SnapshotAssertion from homeassistant.components.wmspro.const import DOMAIN @@ -29,7 +30,7 @@ async def test_cover_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, mock_hub_status_prod_awning: AsyncMock, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, @@ -37,7 +38,7 @@ async def test_cover_device( """Test that a cover device is created correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_configuration_prod_awning_dimmer.mock_calls) == 1 assert len(mock_hub_status_prod_awning.mock_calls) == 2 device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "58717")}) @@ -49,7 +50,7 @@ async def test_cover_update( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, mock_hub_status_prod_awning: AsyncMock, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, @@ -57,7 +58,7 @@ async def test_cover_update( """Test that a cover entity is created and updated correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_configuration_prod_awning_dimmer.mock_calls) == 1 assert len(mock_hub_status_prod_awning.mock_calls) == 2 entity = hass.states.get("cover.markise") @@ -72,21 +73,41 @@ async def test_cover_update( assert len(mock_hub_status_prod_awning.mock_calls) >= 3 +@pytest.mark.parametrize( + ("mock_hub_configuration", "mock_hub_status", "entity_name"), + [ + ( + "mock_hub_configuration_prod_awning_dimmer", + "mock_hub_status_prod_awning", + "cover.markise", + ), + ( + "mock_hub_configuration_prod_roller_shutter", + "mock_hub_status_prod_roller_shutter", + "cover.wohnebene_alle", + ), + ], +) async def test_cover_open_and_close( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, - mock_hub_status_prod_awning: AsyncMock, + mock_hub_configuration: AsyncMock, + mock_hub_status: AsyncMock, mock_action_call: AsyncMock, + request: pytest.FixtureRequest, + entity_name: str, ) -> None: """Test that a cover entity is opened and closed correctly.""" + mock_hub_configuration = request.getfixturevalue(mock_hub_configuration) + mock_hub_status = request.getfixturevalue(mock_hub_status) + assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 - assert len(mock_hub_status_prod_awning.mock_calls) >= 1 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_hub_status.mock_calls) >= 1 - entity = hass.states.get("cover.markise") + entity = hass.states.get(entity_name) assert entity is not None assert entity.state == STATE_CLOSED assert entity.attributes["current_position"] == 0 @@ -95,7 +116,7 @@ async def test_cover_open_and_close( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status_prod_awning.mock_calls) + before = len(mock_hub_status.mock_calls) await hass.services.async_call( Platform.COVER, @@ -104,17 +125,17 @@ async def test_cover_open_and_close( blocking=True, ) - entity = hass.states.get("cover.markise") + entity = hass.states.get(entity_name) assert entity is not None assert entity.state == STATE_OPEN assert entity.attributes["current_position"] == 100 - assert len(mock_hub_status_prod_awning.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status_prod_awning.mock_calls) + before = len(mock_hub_status.mock_calls) await hass.services.async_call( Platform.COVER, @@ -123,28 +144,48 @@ async def test_cover_open_and_close( blocking=True, ) - entity = hass.states.get("cover.markise") + entity = hass.states.get(entity_name) assert entity is not None assert entity.state == STATE_CLOSED assert entity.attributes["current_position"] == 0 - assert len(mock_hub_status_prod_awning.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before +@pytest.mark.parametrize( + ("mock_hub_configuration", "mock_hub_status", "entity_name"), + [ + ( + "mock_hub_configuration_prod_awning_dimmer", + "mock_hub_status_prod_awning", + "cover.markise", + ), + ( + "mock_hub_configuration_prod_roller_shutter", + "mock_hub_status_prod_roller_shutter", + "cover.wohnebene_alle", + ), + ], +) async def test_cover_open_to_pos( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, - mock_hub_status_prod_awning: AsyncMock, + mock_hub_configuration: AsyncMock, + mock_hub_status: AsyncMock, mock_action_call: AsyncMock, + request: pytest.FixtureRequest, + entity_name: str, ) -> None: """Test that a cover entity is opened to correct position.""" + mock_hub_configuration = request.getfixturevalue(mock_hub_configuration) + mock_hub_status = request.getfixturevalue(mock_hub_status) + assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 - assert len(mock_hub_status_prod_awning.mock_calls) >= 1 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_hub_status.mock_calls) >= 1 - entity = hass.states.get("cover.markise") + entity = hass.states.get(entity_name) assert entity is not None assert entity.state == STATE_CLOSED assert entity.attributes["current_position"] == 0 @@ -153,7 +194,7 @@ async def test_cover_open_to_pos( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status_prod_awning.mock_calls) + before = len(mock_hub_status.mock_calls) await hass.services.async_call( Platform.COVER, @@ -162,28 +203,48 @@ async def test_cover_open_to_pos( blocking=True, ) - entity = hass.states.get("cover.markise") + entity = hass.states.get(entity_name) assert entity is not None assert entity.state == STATE_OPEN assert entity.attributes["current_position"] == 50 - assert len(mock_hub_status_prod_awning.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before +@pytest.mark.parametrize( + ("mock_hub_configuration", "mock_hub_status", "entity_name"), + [ + ( + "mock_hub_configuration_prod_awning_dimmer", + "mock_hub_status_prod_awning", + "cover.markise", + ), + ( + "mock_hub_configuration_prod_roller_shutter", + "mock_hub_status_prod_roller_shutter", + "cover.wohnebene_alle", + ), + ], +) async def test_cover_open_and_stop( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, - mock_hub_status_prod_awning: AsyncMock, + mock_hub_configuration: AsyncMock, + mock_hub_status: AsyncMock, mock_action_call: AsyncMock, + request: pytest.FixtureRequest, + entity_name: str, ) -> None: """Test that a cover entity is opened and stopped correctly.""" + mock_hub_configuration = request.getfixturevalue(mock_hub_configuration) + mock_hub_status = request.getfixturevalue(mock_hub_status) + assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 - assert len(mock_hub_status_prod_awning.mock_calls) >= 1 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_hub_status.mock_calls) >= 1 - entity = hass.states.get("cover.markise") + entity = hass.states.get(entity_name) assert entity is not None assert entity.state == STATE_CLOSED assert entity.attributes["current_position"] == 0 @@ -192,7 +253,7 @@ async def test_cover_open_and_stop( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status_prod_awning.mock_calls) + before = len(mock_hub_status.mock_calls) await hass.services.async_call( Platform.COVER, @@ -201,17 +262,17 @@ async def test_cover_open_and_stop( blocking=True, ) - entity = hass.states.get("cover.markise") + entity = hass.states.get(entity_name) assert entity is not None assert entity.state == STATE_OPEN assert entity.attributes["current_position"] == 80 - assert len(mock_hub_status_prod_awning.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status_prod_awning.mock_calls) + before = len(mock_hub_status.mock_calls) await hass.services.async_call( Platform.COVER, @@ -220,8 +281,8 @@ async def test_cover_open_and_stop( blocking=True, ) - entity = hass.states.get("cover.markise") + entity = hass.states.get(entity_name) assert entity is not None assert entity.state == STATE_OPEN assert entity.attributes["current_position"] == 80 - assert len(mock_hub_status_prod_awning.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before diff --git a/tests/components/wmspro/test_diagnostics.py b/tests/components/wmspro/test_diagnostics.py index 930c3f2898e..24698cfc493 100644 --- a/tests/components/wmspro/test_diagnostics.py +++ b/tests/components/wmspro/test_diagnostics.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +import pytest from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -13,20 +14,30 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +@pytest.mark.parametrize( + ("mock_hub_configuration"), + [ + ("mock_hub_configuration_prod_awning_dimmer"), + ("mock_hub_configuration_prod_roller_shutter"), + ], +) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, + mock_hub_configuration: AsyncMock, mock_dest_refresh: AsyncMock, snapshot: SnapshotAssertion, + request: pytest.FixtureRequest, ) -> None: """Test that a config entry can be loaded with DeviceConfig.""" + mock_hub_configuration = request.getfixturevalue(mock_hub_configuration) + assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 - assert len(mock_dest_refresh.mock_calls) == 2 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_dest_refresh.mock_calls) > 0 result = await get_diagnostics_for_config_entry( hass, hass_client, mock_config_entry diff --git a/tests/components/wmspro/test_init.py b/tests/components/wmspro/test_init.py index aeb5f3db152..56857ae86ca 100644 --- a/tests/components/wmspro/test_init.py +++ b/tests/components/wmspro/test_init.py @@ -3,9 +3,13 @@ from unittest.mock import AsyncMock import aiohttp +import pytest +from syrupy import SnapshotAssertion +from homeassistant.components.wmspro.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import setup_config_entry @@ -36,3 +40,49 @@ async def test_config_entry_device_config_refresh_failed( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_refresh.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("mock_hub_configuration", "mock_hub_status"), + [ + ("mock_hub_configuration_prod_awning_dimmer", "mock_hub_status_prod_awning"), + ("mock_hub_configuration_prod_awning_dimmer", "mock_hub_status_prod_dimmer"), + ( + "mock_hub_configuration_prod_roller_shutter", + "mock_hub_status_prod_roller_shutter", + ), + ], +) +async def test_cover_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration: AsyncMock, + mock_hub_status: AsyncMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + request: pytest.FixtureRequest, +) -> None: + """Test that the device is created correctly.""" + mock_hub_configuration = request.getfixturevalue(mock_hub_configuration) + mock_hub_status = request.getfixturevalue(mock_hub_status) + + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_hub_status.mock_calls) > 0 + + device_entries = device_registry.devices.get_devices_for_config_entry_id( + mock_config_entry.entry_id + ) + assert len(device_entries) > 1 + + device_entries = list( + filter( + lambda e: e.identifiers != {(DOMAIN, mock_config_entry.entry_id)}, + device_entries, + ) + ) + assert len(device_entries) > 0 + for device_entry in device_entries: + assert device_entry == snapshot(name=f"device-{device_entry.serial_number}") diff --git a/tests/components/wmspro/test_light.py b/tests/components/wmspro/test_light.py index db53b54a2f6..9f45a821884 100644 --- a/tests/components/wmspro/test_light.py +++ b/tests/components/wmspro/test_light.py @@ -28,7 +28,7 @@ async def test_light_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, mock_hub_status_prod_dimmer: AsyncMock, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, @@ -36,7 +36,7 @@ async def test_light_device( """Test that a light device is created correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_configuration_prod_awning_dimmer.mock_calls) == 1 assert len(mock_hub_status_prod_dimmer.mock_calls) == 2 device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "97358")}) @@ -48,7 +48,7 @@ async def test_light_update( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, mock_hub_status_prod_dimmer: AsyncMock, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, @@ -56,7 +56,7 @@ async def test_light_update( """Test that a light entity is created and updated correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_configuration_prod_awning_dimmer.mock_calls) == 1 assert len(mock_hub_status_prod_dimmer.mock_calls) == 2 entity = hass.states.get("light.licht") @@ -75,14 +75,14 @@ async def test_light_turn_on_and_off( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, mock_hub_status_prod_dimmer: AsyncMock, mock_action_call: AsyncMock, ) -> None: """Test that a light entity is turned on and off correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_configuration_prod_awning_dimmer.mock_calls) == 1 assert len(mock_hub_status_prod_dimmer.mock_calls) >= 1 entity = hass.states.get("light.licht") @@ -133,14 +133,14 @@ async def test_light_dimm_on_and_off( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, mock_hub_status_prod_dimmer: AsyncMock, mock_action_call: AsyncMock, ) -> None: """Test that a light entity is dimmed on and off correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_configuration_prod_awning_dimmer.mock_calls) == 1 assert len(mock_hub_status_prod_dimmer.mock_calls) >= 1 entity = hass.states.get("light.licht") diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 51d4b899d25..c05da654f96 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -55,7 +55,7 @@ async def test_form(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: [], CONF_REMOVE_HOLIDAYS: [], - CONF_LANGUAGE: "de", + CONF_LANGUAGE: "en_US", }, ) await hass.async_block_till_done() @@ -70,7 +70,48 @@ async def test_form(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "language": "de", + "language": "en_US", + } + + +async def test_form_province_no_alias(hass: HomeAssistant) -> None: + """Test we get the forms.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "US", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "US", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], } diff --git a/tests/components/workday/test_init.py b/tests/components/workday/test_init.py index 1e0c9cbebc6..2735175b49b 100644 --- a/tests/components/workday/test_init.py +++ b/tests/components/workday/test_init.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import datetime from freezegun.api import FrozenDateTimeFactory +from holidays.utils import country_holidays from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -50,3 +51,18 @@ async def test_update_options( assert entry_check.state is ConfigEntryState.LOADED state = hass.states.get("binary_sensor.workday_sensor") assert state.state == "off" + + +async def test_workday_subdiv_aliases() -> None: + """Test subdiv aliases in holidays library.""" + + country = country_holidays( + country="FR", + years=2025, + ) + subdiv_aliases = country.get_subdivision_aliases() + assert subdiv_aliases["GES"] == [ # codespell:ignore + "Alsace", + "Champagne-Ardenne", + "Lorraine", + ] diff --git a/tests/components/wyoming/test_conversation.py b/tests/components/wyoming/test_conversation.py index 02b04503962..7278a254d4a 100644 --- a/tests/components/wyoming/test_conversation.py +++ b/tests/components/wyoming/test_conversation.py @@ -192,7 +192,7 @@ async def test_connection_lost( assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.error_code == intent.IntentResponseErrorCode.UNKNOWN assert result.response.speech, "No speech" - assert result.response.speech.get("plain", {}).get("speech") == snapshot() + assert result.response.speech.get("plain", {}).get("speech") == snapshot async def test_oserror( @@ -221,4 +221,4 @@ async def test_oserror( assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.error_code == intent.IntentResponseErrorCode.UNKNOWN assert result.response.speech, "No speech" - assert result.response.speech.get("plain", {}).get("speech") == snapshot() + assert result.response.speech.get("plain", {}).get("speech") == snapshot diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index 0e4bb3da78c..800870f4604 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -35,6 +35,7 @@ from homeassistant.setup import async_setup_component from . import SATELLITE_INFO, WAKE_WORD_INFO, MockAsyncTcpClient from tests.common import MockConfigEntry +from tests.components.tts.common import MockResultStream async def setup_config_entry(hass: HomeAssistant) -> MockConfigEntry: @@ -259,10 +260,6 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.wyoming.assist_satellite.tts.async_get_media_source_audio", - return_value=("wav", get_test_wav()), - ), patch("homeassistant.components.wyoming.assist_satellite._PING_SEND_DELAY", 0), ): entry = await setup_config_entry(hass) @@ -411,10 +408,11 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.synthesize.voice.name == "test voice" # Text-to-speech media + mock_tts_result_stream = MockResultStream(hass, "wav", get_test_wav()) pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.TTS_END, - {"tts_output": {"media_id": "test media id"}}, + {"tts_output": {"token": mock_tts_result_stream.token}}, ) ) async with asyncio.timeout(1): @@ -435,12 +433,6 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: ) assert not device.is_active - # The client should have received another ping by now - async with asyncio.timeout(1): - await mock_client.ping_event.wait() - - assert mock_client.ping is not None - # Pipeline should automatically restart async with asyncio.timeout(1): await run_pipeline_called.wait() @@ -746,10 +738,6 @@ async def test_tts_not_wav(hass: HomeAssistant) -> None: "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", wraps=_async_pipeline_from_audio_stream, ) as mock_run_pipeline, - patch( - "homeassistant.components.wyoming.assist_satellite.tts.async_get_media_source_audio", - return_value=("mp3", bytes(1)), - ), patch( "homeassistant.components.wyoming.assist_satellite.WyomingAssistSatellite._stream_tts", _stream_tts, @@ -779,10 +767,11 @@ async def test_tts_not_wav(hass: HomeAssistant) -> None: await mock_client.synthesize_event.wait() # Text-to-speech media + mock_tts_result_stream = MockResultStream(hass, "mp3", bytes(1)) event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.TTS_END, - {"tts_output": {"media_id": "test media id"}}, + {"tts_output": {"token": mock_tts_result_stream.token}}, ) ) diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 6e0edc022c0..c52b1391038 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -117,7 +117,6 @@ async def test_get_tts_audio_different_formats( assert wav_file.getframerate() == 48000 assert wav_file.getsampwidth() == 2 assert wav_file.getnchannels() == 2 - assert wav_file.getnframes() == wav_file.getframerate() # one second assert mock_client.written == snapshot diff --git a/tests/components/xiaomi_ble/test_sensor.py b/tests/components/xiaomi_ble/test_sensor.py index 11a20a62d02..f5625d4e74d 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -694,21 +694,21 @@ async def test_miscale_v1_uuid(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) mass_non_stabilized_sensor_attr = mass_non_stabilized_sensor.attributes assert mass_non_stabilized_sensor.state == "86.55" assert ( mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] - == "Mi Smart Scale (B5DC) Mass Non Stabilized" + == "Mi Smart Scale (B5DC) Weight non stabilized" ) assert mass_non_stabilized_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_non_stabilized_sensor_attr[ATTR_STATE_CLASS] == "measurement" - mass_sensor = hass.states.get("sensor.mi_smart_scale_b5dc_mass") + mass_sensor = hass.states.get("sensor.mi_smart_scale_b5dc_weight") mass_sensor_attr = mass_sensor.attributes assert mass_sensor.state == "86.55" - assert mass_sensor_attr[ATTR_FRIENDLY_NAME] == "Mi Smart Scale (B5DC) Mass" + assert mass_sensor_attr[ATTR_FRIENDLY_NAME] == "Mi Smart Scale (B5DC) Weight" assert mass_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_sensor_attr[ATTR_STATE_CLASS] == "measurement" @@ -736,22 +736,23 @@ async def test_miscale_v2_uuid(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 3 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_body_composition_scale_b5dc_mass_non_stabilized" + "sensor.mi_body_composition_scale_b5dc_weight_non_stabilized" ) mass_non_stabilized_sensor_attr = mass_non_stabilized_sensor.attributes assert mass_non_stabilized_sensor.state == "85.15" assert ( mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] - == "Mi Body Composition Scale (B5DC) Mass Non Stabilized" + == "Mi Body Composition Scale (B5DC) Weight non stabilized" ) assert mass_non_stabilized_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_non_stabilized_sensor_attr[ATTR_STATE_CLASS] == "measurement" - mass_sensor = hass.states.get("sensor.mi_body_composition_scale_b5dc_mass") + mass_sensor = hass.states.get("sensor.mi_body_composition_scale_b5dc_weight") mass_sensor_attr = mass_sensor.attributes assert mass_sensor.state == "85.15" assert ( - mass_sensor_attr[ATTR_FRIENDLY_NAME] == "Mi Body Composition Scale (B5DC) Mass" + mass_sensor_attr[ATTR_FRIENDLY_NAME] + == "Mi Body Composition Scale (B5DC) Weight" ) assert mass_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_sensor_attr[ATTR_STATE_CLASS] == "measurement" @@ -845,7 +846,7 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) assert mass_non_stabilized_sensor.state == "86.55" @@ -866,7 +867,7 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: await hass.async_block_till_done() mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) # Sleepy devices should keep their state over time @@ -896,7 +897,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) assert mass_non_stabilized_sensor.state == "86.55" @@ -917,7 +918,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) # Sleepy devices should keep their state over time @@ -930,7 +931,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) # Sleepy devices should keep their state over time and restore it diff --git a/tests/components/youtube/test_config_flow.py b/tests/components/youtube/test_config_flow.py index 73652d9b239..2cfb970928d 100644 --- a/tests/components/youtube/test_config_flow.py +++ b/tests/components/youtube/test_config_flow.py @@ -131,7 +131,51 @@ async def test_flow_abort_without_subscriptions( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, ) -> None: - """Check abort flow if user has no subscriptions.""" + """Check abort flow if user has no subscriptions and no own channel.""" + result = await hass.config_entries.flow.async_init( + "youtube", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(SCOPES)}" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + service = MockYouTube( + channel_fixture="youtube/get_no_channel.json", + subscriptions_fixture="youtube/get_no_subscriptions.json", + ) + with ( + patch("homeassistant.components.youtube.async_setup_entry", return_value=True), + patch( + "homeassistant.components.youtube.config_flow.YouTube", return_value=service + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_channel" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_without_subscriptions( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Check flow continues even without subscriptions since user has their own channel.""" result = await hass.config_entries.flow.async_init( "youtube", context={"source": config_entries.SOURCE_USER} ) @@ -163,8 +207,30 @@ async def test_flow_abort_without_subscriptions( ), ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_subscriptions" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "channels" + + # Verify the form schema contains only the user's own channel + schema = result["data_schema"] + channels = schema.schema[CONF_CHANNELS].config["options"] + assert len(channels) == 1 + assert channels[0]["value"] == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + assert "(Your Channel)" in channels[0]["label"] + + # Test selecting the own channel + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert "result" in result + assert result["result"].unique_id == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + assert "token" in result["result"].data + assert result["result"].data["token"]["access_token"] == "mock-access-token" + assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + assert result["options"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} @pytest.mark.usefixtures("current_request_with_host") @@ -373,3 +439,112 @@ async def test_options_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_own_channel_included( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test that the user's own channel is included in the list of selectable channels.""" + result = await hass.config_entries.flow.async_init( + "youtube", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(SCOPES)}" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + with ( + patch( + "homeassistant.components.youtube.async_setup_entry", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.youtube.config_flow.YouTube", + return_value=MockYouTube(), + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "channels" + + # Verify the form schema contains the user's own channel + schema = result["data_schema"] + channels = schema.schema[CONF_CHANNELS].config["options"] + assert any( + channel["value"] == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + and "(Your Channel)" in channel["label"] + for channel in channels + ) + + # Test selecting both own channel and a subscribed channel + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw", "UC_x5XG1OV2P6uZZ5FSM9Ttw"] + }, + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert "result" in result + assert result["result"].unique_id == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + assert "token" in result["result"].data + assert result["result"].data["token"]["access_token"] == "mock-access-token" + assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + assert result["options"] == { + CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw", "UC_x5XG1OV2P6uZZ5FSM9Ttw"] + } + + +async def test_options_flow_own_channel( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test the options flow includes the user's own channel.""" + await setup_integration() + with patch( + "homeassistant.components.youtube.config_flow.YouTube", + return_value=MockYouTube(), + ): + entry = hass.config_entries.async_entries(DOMAIN)[0] + 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["step_id"] == "init" + + # Verify the form schema contains the user's own channel + schema = result["data_schema"] + channels = schema.schema[CONF_CHANNELS].config["options"] + assert any( + channel["value"] == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + and "(Your Channel)" in channel["label"] + for channel in channels + ) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 56262600511..847727796bb 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -14,6 +14,7 @@ from zeroconf.asyncio import AsyncServiceInfo from homeassistant import config_entries from homeassistant.components import zeroconf +from homeassistant.components.zeroconf import discovery from homeassistant.const import ( EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_CLOSE, @@ -181,10 +182,10 @@ async def test_setup(hass: HomeAssistant, mock_async_zeroconf: MagicMock) -> Non ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock + discovery, "AsyncServiceBrowser", side_effect=service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -214,7 +215,7 @@ async def test_setup_with_overly_long_url_and_name( """Test we still setup with long urls and names.""" with ( patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.get_url", return_value=( @@ -240,7 +241,7 @@ async def test_setup_with_overly_long_url_and_name( ), ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo.async_request", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo.async_request", ), ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) @@ -258,9 +259,9 @@ async def test_setup_with_defaults( """Test default interface config.""" with ( patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -302,10 +303,10 @@ async def test_zeroconf_match_macaddress(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): @@ -351,10 +352,10 @@ async def test_zeroconf_match_manufacturer(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_manufacturer("Samsung Electronics"), ), ): @@ -392,10 +393,10 @@ async def test_zeroconf_match_model(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_model("appletv"), ), ): @@ -433,10 +434,10 @@ async def test_zeroconf_match_manufacturer_not_present(hass: HomeAssistant) -> N ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("aabbccddeeff"), ), ): @@ -469,10 +470,10 @@ async def test_zeroconf_no_match(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): @@ -509,10 +510,10 @@ async def test_zeroconf_no_match_manufacturer(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_manufacturer("Not Samsung Electronics"), ), ): @@ -540,14 +541,14 @@ async def test_homekit_match_partial_space(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("LIFX bulb", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -588,14 +589,14 @@ async def test_device_with_invalid_name( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=BadTypeInNameException, ), ): @@ -624,14 +625,14 @@ async def test_homekit_match_partial_dash(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock( "Smart Bridge-001", HOMEKIT_STATUS_UNPAIRED ), @@ -662,14 +663,14 @@ async def test_homekit_match_partial_fnmatch(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("YLDP13YL", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -698,14 +699,14 @@ async def test_homekit_match_full(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("BSB002", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -737,14 +738,14 @@ async def test_homekit_already_paired(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("tado", HOMEKIT_STATUS_PAIRED), ), ): @@ -774,14 +775,14 @@ async def test_homekit_invalid_paring_status(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("Smart Bridge", b"invalid"), ), ): @@ -805,10 +806,10 @@ async def test_homekit_not_paired(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock + discovery, "AsyncServiceBrowser", side_effect=service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock( "this_will_not_match_any_integration", HOMEKIT_STATUS_UNPAIRED ), @@ -847,14 +848,14 @@ async def test_homekit_controller_still_discovered_unpaired_for_cloud( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("Rachio-xyz", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -892,14 +893,14 @@ async def test_homekit_controller_still_discovered_unpaired_for_polling( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("iSmartGate", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -1053,9 +1054,9 @@ async def test_removed_ignored(hass: HomeAssistant) -> None: ) with ( - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ) as mock_service_info, ): @@ -1088,13 +1089,13 @@ async def test_async_detect_interfaces_setting_non_loopback_route( with ( patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1176,13 +1177,13 @@ async def test_async_detect_interfaces_setting_empty_route_linux( patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1210,13 +1211,13 @@ async def test_async_detect_interfaces_setting_empty_route_freebsd( patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1261,13 +1262,13 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_linux( patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1290,13 +1291,13 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1319,13 +1320,13 @@ async def test_async_detect_interfaces_explicitly_before_setup( patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1359,14 +1360,14 @@ async def test_setup_with_disallowed_characters_in_local_name( """Test we still setup with disallowed characters in the location name.""" with ( patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch.object( hass.config, "location_name", "My.House", ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo.async_request", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo.async_request", ), ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) @@ -1422,10 +1423,10 @@ async def test_zeroconf_removed(hass: HomeAssistant) -> None: ) as mock_async_progress_by_init_data_type, patch.object(hass.config_entries.flow, "async_abort") as mock_async_abort, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=_device_removed_mock + discovery, "AsyncServiceBrowser", side_effect=_device_removed_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): @@ -1545,10 +1546,10 @@ async def test_zeroconf_rediscover( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): @@ -1665,10 +1666,10 @@ async def test_zeroconf_rediscover_no_match( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): diff --git a/tests/components/zeroconf/test_usage.py b/tests/components/zeroconf/test_usage.py index e79f2319915..2e186bc39d0 100644 --- a/tests/components/zeroconf/test_usage.py +++ b/tests/components/zeroconf/test_usage.py @@ -3,7 +3,6 @@ from unittest.mock import Mock, patch import pytest -import zeroconf from homeassistant.components.zeroconf import async_get_instance from homeassistant.components.zeroconf.usage import install_multiple_zeroconf_catcher @@ -15,6 +14,16 @@ from tests.common import extract_stack_to_frame DOMAIN = "zeroconf" +class MockZeroconf: + """Mock Zeroconf class.""" + + def __init__(self, *args, **kwargs) -> None: + """Initialize the mock.""" + + def __new__(cls, *args, **kwargs) -> "MockZeroconf": + """Return the shared instance.""" + + @pytest.mark.usefixtures("mock_async_zeroconf", "mock_zeroconf") async def test_multiple_zeroconf_instances( hass: HomeAssistant, caplog: pytest.LogCaptureFixture @@ -24,12 +33,13 @@ async def test_multiple_zeroconf_instances( zeroconf_instance = await async_get_instance(hass) - install_multiple_zeroconf_catcher(zeroconf_instance) + with patch("zeroconf.Zeroconf", MockZeroconf): + install_multiple_zeroconf_catcher(zeroconf_instance) - new_zeroconf_instance = zeroconf.Zeroconf() - assert new_zeroconf_instance == zeroconf_instance + new_zeroconf_instance = MockZeroconf() + assert new_zeroconf_instance == zeroconf_instance - assert "Zeroconf" in caplog.text + assert "Zeroconf" in caplog.text @pytest.mark.usefixtures("mock_async_zeroconf", "mock_zeroconf") @@ -41,44 +51,45 @@ async def test_multiple_zeroconf_instances_gives_shared( zeroconf_instance = await async_get_instance(hass) - install_multiple_zeroconf_catcher(zeroconf_instance) + with patch("zeroconf.Zeroconf", MockZeroconf): + install_multiple_zeroconf_catcher(zeroconf_instance) - correct_frame = Mock( - filename="/config/custom_components/burncpu/light.py", - lineno="23", - line="self.light.is_on", - ) - with ( - patch( - "homeassistant.helpers.frame.linecache.getline", - return_value=correct_frame.line, - ), - patch( - "homeassistant.helpers.frame.get_current_frame", - return_value=extract_stack_to_frame( - [ - Mock( - filename="/home/dev/homeassistant/core.py", - lineno="23", - line="do_something()", - ), - correct_frame, - Mock( - filename="/home/dev/homeassistant/components/zeroconf/usage.py", - lineno="23", - line="self.light.is_on", - ), - Mock( - filename="/home/dev/mdns/lights.py", - lineno="2", - line="something()", - ), - ] + correct_frame = Mock( + filename="/config/custom_components/burncpu/light.py", + lineno="23", + line="self.light.is_on", + ) + with ( + patch( + "homeassistant.helpers.frame.linecache.getline", + return_value=correct_frame.line, ), - ), - ): - assert zeroconf.Zeroconf() == zeroconf_instance + patch( + "homeassistant.helpers.frame.get_current_frame", + return_value=extract_stack_to_frame( + [ + Mock( + filename="/home/dev/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + correct_frame, + Mock( + filename="/home/dev/homeassistant/components/zeroconf/usage.py", + lineno="23", + line="self.light.is_on", + ), + Mock( + filename="/home/dev/mdns/lights.py", + lineno="2", + line="something()", + ), + ] + ), + ), + ): + assert MockZeroconf() == zeroconf_instance - assert "custom_components/burncpu/light.py" in caplog.text - assert "23" in caplog.text - assert "self.light.is_on" in caplog.text + assert "custom_components/burncpu/light.py" in caplog.text + assert "23" in caplog.text + assert "self.light.is_on" in caplog.text diff --git a/tests/components/zeroconf/test_websocket_api.py b/tests/components/zeroconf/test_websocket_api.py new file mode 100644 index 00000000000..9677b3e34fd --- /dev/null +++ b/tests/components/zeroconf/test_websocket_api.py @@ -0,0 +1,194 @@ +"""The tests for the zeroconf WebSocket API.""" + +import asyncio +import socket +from unittest.mock import patch + +from zeroconf import ( + DNSAddress, + DNSPointer, + DNSService, + DNSText, + RecordUpdate, + const, + current_time_millis, +) + +from homeassistant.components.zeroconf import DOMAIN, async_get_async_instance +from homeassistant.core import HomeAssistant +from homeassistant.generated import zeroconf as zc_gen +from homeassistant.setup import async_setup_component + +from tests.typing import WebSocketGenerator + + +async def test_subscribe_discovery( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test zeroconf subscribe_discovery.""" + instance = await async_get_async_instance(hass) + instance.zeroconf.cache.async_add_records( + [ + DNSPointer( + "_fakeservice._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + "wrong._wrongservice._tcp.local.", + ), + DNSPointer( + "_fakeservice._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + "foo2._fakeservice._tcp.local.", + ), + DNSService( + "foo2._fakeservice._tcp.local.", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_OTHER_TTL, + 0, + 0, + 1234, + "foo2.local.", + ), + DNSAddress( + "foo2.local.", + const._TYPE_A, + const._CLASS_IN, + const._DNS_HOST_TTL, + socket.inet_aton("127.0.0.1"), + ), + DNSText( + "foo2.local.", + const._TYPE_TXT, + const._CLASS_IN, + const._DNS_HOST_TTL, + b"\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5" + b"\x05c#=12\x04s#=1", + ), + DNSPointer( + "_fakeservice._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + "foo3._fakeservice._tcp.local.", + ), + DNSService( + "foo3._fakeservice._tcp.local.", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_OTHER_TTL, + 0, + 0, + 1234, + "foo3.local.", + ), + DNSText( + "foo3.local.", + const._TYPE_TXT, + const._CLASS_IN, + const._DNS_HOST_TTL, + b"\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5" + b"\x05c#=12\x04s#=1", + ), + ] + ) + with patch.dict( + zc_gen.ZEROCONF, + {"_fakeservice._tcp.local.": []}, + clear=True, + ): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "zeroconf/subscribe_discovery", + } + ) + 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": [ + { + "ip_addresses": ["127.0.0.1"], + "name": "foo2._fakeservice._tcp.local.", + "port": 1234, + "properties": {}, + "type": "_fakeservice._tcp.local.", + } + ] + } + + # now late inject the address record + records = [ + DNSAddress( + "foo3.local.", + const._TYPE_A, + const._CLASS_IN, + const._DNS_HOST_TTL, + socket.inet_aton("127.0.0.1"), + ), + ] + instance.zeroconf.cache.async_add_records(records) + instance.zeroconf.record_manager.async_updates( + current_time_millis(), + [RecordUpdate(record, None) for record in records], + ) + # Now for the add + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "ip_addresses": ["127.0.0.1"], + "name": "foo3._fakeservice._tcp.local.", + "port": 1234, + "properties": {}, + "type": "_fakeservice._tcp.local.", + } + ] + } + # Now for the update + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "ip_addresses": ["127.0.0.1"], + "name": "foo3._fakeservice._tcp.local.", + "port": 1234, + "properties": {}, + "type": "_fakeservice._tcp.local.", + } + ] + } + + # now move time forward and remove the record + future = current_time_millis() + (4500 * 1000) + records = instance.zeroconf.cache.async_expire(future) + record_updates = [RecordUpdate(record, record) for record in records] + instance.zeroconf.record_manager.async_updates(future, record_updates) + instance.zeroconf.record_manager.async_updates_complete(True) + + removes: set[str] = set() + for _ in range(3): + async with asyncio.timeout(1): + response = await client.receive_json() + assert "remove" in response["event"] + removes.add(next(iter(response["event"]["remove"]))["name"]) + + assert len(removes) == 3 + assert removes == { + "foo2._fakeservice._tcp.local.", + "foo3._fakeservice._tcp.local.", + "wrong._wrongservice._tcp.local.", + } diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 96a61a6628b..df61fb499d2 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -17,6 +17,7 @@ from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE import zigpy.device import zigpy.group import zigpy.profiles +from zigpy.profiles import zha import zigpy.quirks import zigpy.state import zigpy.types @@ -173,6 +174,7 @@ async def zigpy_app_controller(): dev.model = "Coordinator Model" ep = dev.add_endpoint(1) + ep.profile_id = zha.PROFILE_ID ep.add_input_cluster(Basic.cluster_id) ep.add_input_cluster(Groups.cluster_id) diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index 7a599b00a21..44fb913489d 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -154,31 +154,21 @@ # name: test_diagnostics_for_device dict({ 'active_coordinator': False, - 'area_id': None, 'available': True, - 'cluster_details': dict({ + 'device_type': 'EndDevice', + 'endpoints': dict({ '1': dict({ 'device_type': dict({ 'id': 1025, 'name': 'IAS_ANCILLARY_CONTROL', }), - 'in_clusters': dict({ - '0x0500': dict({ - 'attributes': dict({ - '0x0000': dict({ - 'attribute': "ZCLAttributeDef(id=0x0000, name='zone_state', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", - 'value': None, - }), - '0x0001': dict({ - 'attribute': "ZCLAttributeDef(id=0x0001, name='zone_type', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", - 'value': None, - }), - '0x0002': dict({ - 'attribute': "ZCLAttributeDef(id=0x0002, name='zone_status', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", - 'value': None, - }), - '0x0010': dict({ - 'attribute': "ZCLAttributeDef(id=0x0010, name='cie_addr', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", + 'in_clusters': list([ + dict({ + 'attributes': list([ + dict({ + 'id': '0x0010', + 'name': 'cie_addr', + 'unsupported': False, 'value': list([ 50, 79, @@ -189,61 +179,82 @@ 21, 0, ]), + 'zcl_type': 'EUI64', }), - '0x0011': dict({ - 'attribute': "ZCLAttributeDef(id=0x0011, name='zone_id', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", + dict({ + 'id': '0x0013', + 'name': 'current_zone_sensitivity_level', + 'unsupported': False, 'value': None, + 'zcl_type': 'uint8', }), - '0x0012': dict({ - 'attribute': "ZCLAttributeDef(id=0x0012, name='num_zone_sensitivity_levels_supported', type=, zcl_type=, access=, mandatory=False, is_manufacturer_specific=False)", + dict({ + 'id': '0x0012', + 'name': 'num_zone_sensitivity_levels_supported', + 'unsupported': True, 'value': None, + 'zcl_type': 'uint8', }), - '0x0013': dict({ - 'attribute': "ZCLAttributeDef(id=0x0013, name='current_zone_sensitivity_level', type=, zcl_type=, access=, mandatory=False, is_manufacturer_specific=False)", + dict({ + 'id': '0x0011', + 'name': 'zone_id', + 'unsupported': False, 'value': None, + 'zcl_type': 'uint8', }), - }), + dict({ + 'id': '0x0000', + 'name': 'zone_state', + 'unsupported': False, + 'value': None, + 'zcl_type': 'enum8', + }), + dict({ + 'id': '0x0002', + 'name': 'zone_status', + 'unsupported': False, + 'value': None, + 'zcl_type': 'map16', + }), + dict({ + 'id': '0x0001', + 'name': 'zone_type', + 'unsupported': False, + 'value': None, + 'zcl_type': 'uint16', + }), + ]), + 'cluster_id': '0x0500', 'endpoint_attribute': 'ias_zone', - 'unsupported_attributes': list([ - 18, - 'current_zone_sensitivity_level', - ]), }), - '0x0501': dict({ - 'attributes': dict({ - '0xfffd': dict({ - 'attribute': "ZCLAttributeDef(id=0xFFFD, name='cluster_revision', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", + dict({ + 'attributes': list([ + dict({ + 'id': '0xfffd', + 'name': 'cluster_revision', + 'unsupported': False, 'value': None, + 'zcl_type': 'uint16', }), - '0xfffe': dict({ - 'attribute': "ZCLAttributeDef(id=0xFFFE, name='reporting_status', type=, zcl_type=, access=, mandatory=False, is_manufacturer_specific=False)", + dict({ + 'id': '0xfffe', + 'name': 'reporting_status', + 'unsupported': False, 'value': None, + 'zcl_type': 'enum8', }), - }), + ]), + 'cluster_id': '0x0501', 'endpoint_attribute': 'ias_ace', - 'unsupported_attributes': list([ - 4096, - 'unknown_attribute_name', - ]), }), - }), - 'out_clusters': dict({ - }), + ]), + 'out_clusters': list([ + ]), 'profile_id': 260, }), }), - 'device_type': 'EndDevice', - 'endpoint_names': list([ - dict({ - 'name': 'IAS_ANCILLARY_CONTROL', - }), - ]), - 'entities': list([ - dict({ - 'entity_id': 'alarm_control_panel.fakemanufacturer_fakemodel_alarm_control_panel', - 'name': 'FakeManufacturer FakeModel', - }), - ]), + 'friendly_manufacturer': 'FakeManufacturer', + 'friendly_model': 'FakeModel', 'ieee': '**REDACTED**', 'lqi': None, 'manufacturer': 'FakeManufacturer', @@ -252,7 +263,22 @@ 'name': 'FakeManufacturer FakeModel', 'neighbors': list([ ]), - 'nwk': 47004, + 'node_descriptor': dict({ + 'aps_flags': 0, + 'complex_descriptor_available': False, + 'descriptor_capability_field': 0, + 'frequency_band': 8, + 'logical_type': 'EndDevice', + 'mac_capability_flags': 140, + 'manufacturer_code': 4098, + 'maximum_buffer_size': 82, + 'maximum_incoming_transfer_size': 82, + 'maximum_outgoing_transfer_size': 82, + 'reserved': 0, + 'server_mask': 0, + 'user_descriptor_available': False, + }), + 'nwk': '0xB79C', 'power_source': 'Mains', 'quirk_applied': False, 'quirk_class': 'zigpy.device.Device', @@ -260,37 +286,100 @@ 'routes': list([ ]), 'rssi': None, - 'signature': dict({ - 'endpoints': dict({ - '1': dict({ - 'device_type': '0x0401', - 'input_clusters': list([ - '0x0500', - '0x0501', - ]), - 'output_clusters': list([ - ]), - 'profile_id': '0x0104', + 'version': 1, + 'zha_lib_entities': dict({ + 'alarm_control_panel': list([ + dict({ + 'info_object': dict({ + 'available': True, + 'class_name': 'AlarmControlPanel', + 'cluster_handlers': list([ + dict({ + 'class_name': 'IasAceClusterHandler', + 'cluster': dict({ + 'id': 1281, + 'name': 'IAS Ancillary Control Equipment', + 'type': 'server', + }), + 'endpoint_id': 1, + 'generic_id': 'cluster_handler_0x0501', + 'id': '1:0x0501', + 'status': 'INITIALIZED', + 'unique_id': '**REDACTED**', + 'value_attribute': None, + }), + ]), + 'code_arm_required': False, + 'code_format': 'number', + 'device_class': None, + 'device_ieee': '**REDACTED**', + 'enabled': True, + 'endpoint_id': 1, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'fallback_name': None, + 'group_id': None, + 'migrate_unique_ids': list([ + ]), + 'platform': 'alarm_control_panel', + 'primary': False, + 'state_class': None, + 'supported_features': 15, + 'translation_key': 'alarm_control_panel', + 'unique_id': '**REDACTED**', + }), + 'state': dict({ + 'available': True, + 'class_name': 'AlarmControlPanel', + 'state': 'disarmed', + }), }), - }), - 'manufacturer': 'FakeManufacturer', - 'model': 'FakeModel', - 'node_descriptor': dict({ - 'aps_flags': 0, - 'complex_descriptor_available': 0, - 'descriptor_capability_field': 0, - 'frequency_band': 8, - 'logical_type': 2, - 'mac_capability_flags': 140, - 'manufacturer_code': 4098, - 'maximum_buffer_size': 82, - 'maximum_incoming_transfer_size': 82, - 'maximum_outgoing_transfer_size': 82, - 'reserved': 0, - 'server_mask': 0, - 'user_descriptor_available': 0, - }), + ]), + 'binary_sensor': list([ + dict({ + 'info_object': dict({ + 'attribute_name': 'zone_status', + 'available': True, + 'class_name': 'IASZone', + 'cluster_handlers': list([ + dict({ + 'class_name': 'IASZoneClusterHandler', + 'cluster': dict({ + 'id': 1280, + 'name': 'IAS Zone', + 'type': 'server', + }), + 'endpoint_id': 1, + 'generic_id': 'cluster_handler_0x0500', + 'id': '1:0x0500', + 'status': 'INITIALIZED', + 'unique_id': '**REDACTED**', + 'value_attribute': None, + }), + ]), + 'device_class': None, + 'device_ieee': '**REDACTED**', + 'enabled': True, + 'endpoint_id': 1, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'fallback_name': None, + 'group_id': None, + 'migrate_unique_ids': list([ + ]), + 'platform': 'binary_sensor', + 'primary': True, + 'state_class': None, + 'translation_key': 'ias_zone', + 'unique_id': '**REDACTED**', + }), + 'state': dict({ + 'available': True, + 'class_name': 'IASZone', + 'state': False, + }), + }), + ]), }), - 'user_given_name': None, }) # --- diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index 4bc4d6c97cf..70fdac2c313 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -80,8 +80,8 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: # load up cover domain cluster = zigpy_device.endpoints[1].window_covering cluster.PLUGGED_ATTR_READS = { - WCAttrs.current_position_lift_percentage.name: 0, - WCAttrs.current_position_tilt_percentage.name: 100, + WCAttrs.current_position_lift_percentage.name: 0, # Zigbee open % + WCAttrs.current_position_tilt_percentage.name: 100, # Zigbee closed % WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), } @@ -114,8 +114,8 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: state = hass.states.get(entity_id) assert state assert state.state == CoverState.OPEN - assert state.attributes[ATTR_CURRENT_POSITION] == 100 - assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + assert state.attributes[ATTR_CURRENT_POSITION] == 100 # HA open % + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 # HA closed % # test that the state has changed from open to closed await send_attributes_report( @@ -164,7 +164,9 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: await send_attributes_report( hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 0} ) - assert hass.states.get(entity_id).state == CoverState.OPEN + assert ( + hass.states.get(entity_id).state == CoverState.CLOSED + ) # CLOSED lift state currently takes precedence over OPEN tilt with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 8bee821654d..6708250e448 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -209,7 +209,7 @@ async def test_action( cluster_handler = ( gateway.get_device(zigpy_device.ieee) .endpoints[1] - .client_cluster_handlers["1:0x0006"] + .client_cluster_handlers["1:0x0006_client"] ) cluster_handler.zha_send_event(COMMAND_SINGLE, []) await hass.async_block_till_done() @@ -252,7 +252,7 @@ async def test_invalid_zha_event_type( cluster_handler = ( gateway.get_device(zigpy_device.ieee) .endpoints[1] - .client_cluster_handlers["1:0x0006"] + .client_cluster_handlers["1:0x0006_client"] ) # `zha_send_event` accepts only zigpy responses, lists, and dicts diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 09b2d155547..ace3029dac9 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -199,6 +199,7 @@ async def test_if_fires_on_event( ) ep = zigpy_device.add_endpoint(1) ep.add_output_cluster(0x0006) + ep.profile_id = zigpy.profiles.zha.PROFILE_ID zigpy_device.device_automation_triggers = { (SHAKEN, SHAKEN): {COMMAND: COMMAND_SHAKE}, diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 88fb9974c1b..863ea3964ab 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -62,10 +62,10 @@ async def async_test_temperature(hass: HomeAssistant, cluster: Cluster, entity_i async def async_test_pressure(hass: HomeAssistant, cluster: Cluster, entity_id: str): """Test pressure sensor.""" await send_attributes_report(hass, cluster, {1: 1, 0: 1000, 2: 10000}) - assert_state(hass, entity_id, "1000", UnitOfPressure.HPA) + assert_state(hass, entity_id, "1000.0", UnitOfPressure.HPA) await send_attributes_report(hass, cluster, {0: 1000, 20: -1, 16: 10000}) - assert_state(hass, entity_id, "1000", UnitOfPressure.HPA) + assert_state(hass, entity_id, "1000.0", UnitOfPressure.HPA) async def async_test_illuminance(hass: HomeAssistant, cluster: Cluster, entity_id: str): @@ -167,14 +167,14 @@ async def async_test_electrical_measurement( # update divisor cached value await send_attributes_report(hass, cluster, {"ac_power_divisor": 1}) await send_attributes_report(hass, cluster, {0: 1, 1291: 100, 10: 1000}) - assert_state(hass, entity_id, "100", UnitOfPower.WATT) + assert_state(hass, entity_id, "100.0", UnitOfPower.WATT) await send_attributes_report(hass, cluster, {0: 1, 1291: 99, 10: 1000}) - assert_state(hass, entity_id, "99", UnitOfPower.WATT) + assert_state(hass, entity_id, "99.0", UnitOfPower.WATT) await send_attributes_report(hass, cluster, {"ac_power_divisor": 10}) await send_attributes_report(hass, cluster, {0: 1, 1291: 1000, 10: 5000}) - assert_state(hass, entity_id, "100", UnitOfPower.WATT) + assert_state(hass, entity_id, "100.0", UnitOfPower.WATT) await send_attributes_report(hass, cluster, {0: 1, 1291: 99, 10: 5000}) assert_state(hass, entity_id, "9.9", UnitOfPower.WATT) @@ -191,14 +191,14 @@ async def async_test_em_apparent_power( # update divisor cached value await send_attributes_report(hass, cluster, {"ac_power_divisor": 1}) await send_attributes_report(hass, cluster, {0: 1, 0x050F: 100, 10: 1000}) - assert_state(hass, entity_id, "100", UnitOfApparentPower.VOLT_AMPERE) + assert_state(hass, entity_id, "100.0", UnitOfApparentPower.VOLT_AMPERE) await send_attributes_report(hass, cluster, {0: 1, 0x050F: 99, 10: 1000}) - assert_state(hass, entity_id, "99", UnitOfApparentPower.VOLT_AMPERE) + assert_state(hass, entity_id, "99.0", UnitOfApparentPower.VOLT_AMPERE) await send_attributes_report(hass, cluster, {"ac_power_divisor": 10}) await send_attributes_report(hass, cluster, {0: 1, 0x050F: 1000, 10: 5000}) - assert_state(hass, entity_id, "100", UnitOfApparentPower.VOLT_AMPERE) + assert_state(hass, entity_id, "100.0", UnitOfApparentPower.VOLT_AMPERE) await send_attributes_report(hass, cluster, {0: 1, 0x050F: 99, 10: 5000}) assert_state(hass, entity_id, "9.9", UnitOfApparentPower.VOLT_AMPERE) @@ -211,17 +211,17 @@ async def async_test_em_power_factor( # update divisor cached value await send_attributes_report(hass, cluster, {"ac_power_divisor": 1}) await send_attributes_report(hass, cluster, {0: 1, 0x0510: 100, 10: 1000}) - assert_state(hass, entity_id, "100", PERCENTAGE) + assert_state(hass, entity_id, "100.0", PERCENTAGE) await send_attributes_report(hass, cluster, {0: 1, 0x0510: 99, 10: 1000}) - assert_state(hass, entity_id, "99", PERCENTAGE) + assert_state(hass, entity_id, "99.0", PERCENTAGE) await send_attributes_report(hass, cluster, {"ac_power_divisor": 10}) await send_attributes_report(hass, cluster, {0: 1, 0x0510: 100, 10: 5000}) - assert_state(hass, entity_id, "100", PERCENTAGE) + assert_state(hass, entity_id, "100.0", PERCENTAGE) await send_attributes_report(hass, cluster, {0: 1, 0x0510: 99, 10: 5000}) - assert_state(hass, entity_id, "99", PERCENTAGE) + assert_state(hass, entity_id, "99.0", PERCENTAGE) async def async_test_em_rms_current( @@ -230,14 +230,14 @@ async def async_test_em_rms_current( """Test electrical measurement RMS Current sensor.""" await send_attributes_report(hass, cluster, {0: 1, 0x0508: 1234, 10: 1000}) - assert_state(hass, entity_id, "1.2", UnitOfElectricCurrent.AMPERE) + assert_state(hass, entity_id, "1.234", UnitOfElectricCurrent.AMPERE) await send_attributes_report(hass, cluster, {"ac_current_divisor": 10}) await send_attributes_report(hass, cluster, {0: 1, 0x0508: 236, 10: 1000}) assert_state(hass, entity_id, "23.6", UnitOfElectricCurrent.AMPERE) await send_attributes_report(hass, cluster, {0: 1, 0x0508: 1236, 10: 1000}) - assert_state(hass, entity_id, "124", UnitOfElectricCurrent.AMPERE) + assert_state(hass, entity_id, "123.6", UnitOfElectricCurrent.AMPERE) assert "rms_current_max" not in hass.states.get(entity_id).attributes await send_attributes_report(hass, cluster, {0: 1, 0x050A: 88, 10: 5000}) @@ -250,18 +250,18 @@ async def async_test_em_rms_voltage( """Test electrical measurement RMS Voltage sensor.""" await send_attributes_report(hass, cluster, {0: 1, 0x0505: 1234, 10: 1000}) - assert_state(hass, entity_id, "123", UnitOfElectricPotential.VOLT) + assert_state(hass, entity_id, "123.4", UnitOfElectricPotential.VOLT) await send_attributes_report(hass, cluster, {0: 1, 0x0505: 234, 10: 1000}) assert_state(hass, entity_id, "23.4", UnitOfElectricPotential.VOLT) await send_attributes_report(hass, cluster, {"ac_voltage_divisor": 100}) await send_attributes_report(hass, cluster, {0: 1, 0x0505: 2236, 10: 1000}) - assert_state(hass, entity_id, "22.4", UnitOfElectricPotential.VOLT) + assert_state(hass, entity_id, "22.36", UnitOfElectricPotential.VOLT) assert "rms_voltage_max" not in hass.states.get(entity_id).attributes await send_attributes_report(hass, cluster, {0: 1, 0x0507: 888, 10: 5000}) - assert hass.states.get(entity_id).attributes["rms_voltage_max"] == 8.9 + assert hass.states.get(entity_id).attributes["rms_voltage_max"] == 8.88 async def async_test_powerconfiguration( @@ -269,7 +269,7 @@ async def async_test_powerconfiguration( ): """Test powerconfiguration/battery sensor.""" await send_attributes_report(hass, cluster, {33: 98}) - assert_state(hass, entity_id, "49", "%") + assert_state(hass, entity_id, "49.0", "%") assert hass.states.get(entity_id).attributes["battery_voltage"] == 2.9 assert hass.states.get(entity_id).attributes["battery_quantity"] == 3 assert hass.states.get(entity_id).attributes["battery_size"] == "AAA" @@ -288,7 +288,7 @@ async def async_test_powerconfiguration2( assert_state(hass, entity_id, STATE_UNKNOWN, "%") await send_attributes_report(hass, cluster, {33: 98}) - assert_state(hass, entity_id, "49", "%") + assert_state(hass, entity_id, "49.0", "%") async def async_test_device_temperature( @@ -317,7 +317,7 @@ async def async_test_pi_heating_demand( await send_attributes_report( hass, cluster, {Thermostat.AttributeDefs.pi_heating_demand.id: 1} ) - assert_state(hass, entity_id, "1", "%") + assert_state(hass, entity_id, "1.0", "%") @pytest.mark.parametrize( diff --git a/tests/components/zone/test_trigger.py b/tests/components/zone/test_trigger.py index a28b3c0592a..27276c6905f 100644 --- a/tests/components/zone/test_trigger.py +++ b/tests/components/zone/test_trigger.py @@ -17,22 +17,20 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture(autouse=True) -def setup_comp(hass: HomeAssistant) -> None: +async def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" mock_component(hass, "group") - hass.loop.run_until_complete( - async_setup_component( - hass, - zone.DOMAIN, - { - "zone": { - "name": "test", - "latitude": 32.880837, - "longitude": -117.237561, - "radius": 250, - } - }, - ) + await async_setup_component( + hass, + zone.DOMAIN, + { + "zone": { + "name": "test", + "latitude": 32.880837, + "longitude": -117.237561, + "radius": 250, + } + }, ) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index ce7b0e0109e..e4e757ad363 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -509,6 +509,15 @@ def aeotec_smart_switch_7_state_fixture() -> NodeDataType: ) +@pytest.fixture(name="zcombo_smoke_co_alarm_state") +def zcombo_smoke_co_alarm_state_fixture() -> NodeDataType: + """Load node with fixture data for ZCombo-G Smoke/CO Alarm.""" + return cast( + NodeDataType, + load_json_object_fixture("zcombo_smoke_co_alarm_state.json", DOMAIN), + ) + + # model fixtures @@ -554,6 +563,7 @@ def mock_client_fixture( client.connect = AsyncMock(side_effect=connect) client.listen = AsyncMock(side_effect=listen) client.disconnect = AsyncMock(side_effect=disconnect) + client.disable_server_logging = MagicMock() client.driver = Driver( client, copy.deepcopy(controller_state), copy.deepcopy(log_config_state) ) @@ -1252,3 +1262,13 @@ def aeotec_smart_switch_7_fixture( node = Node(client, aeotec_smart_switch_7_state) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="zcombo_smoke_co_alarm") +def zcombo_smoke_co_alarm_fixture( + client: MagicMock, zcombo_smoke_co_alarm_state: NodeDataType +) -> Node: + """Load node for ZCombo-G Smoke/CO Alarm.""" + node = Node(client, zcombo_smoke_co_alarm_state) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/zcombo_smoke_co_alarm_state.json b/tests/components/zwave_js/fixtures/zcombo_smoke_co_alarm_state.json new file mode 100644 index 00000000000..c7417859f1c --- /dev/null +++ b/tests/components/zwave_js/fixtures/zcombo_smoke_co_alarm_state.json @@ -0,0 +1,854 @@ +{ + "nodeId": 3, + "index": 0, + "installerIcon": 3073, + "userIcon": 3073, + "status": 1, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 312, + "productId": 3, + "productType": 1, + "firmwareVersion": "11.0.0", + "zwavePlusVersion": 1, + "deviceConfig": { + "filename": "/data/db/devices/0x0138/zcombo-g.json", + "isEmbedded": true, + "manufacturer": "First Alert (BRK Brands Inc)", + "manufacturerId": 312, + "label": "ZCOMBO", + "description": "ZCombo-G Smoke/CO Alarm", + "devices": [ + { + "productType": 1, + "productId": 3 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "metadata": { + "wakeup": "WAKEUP\n1. Slide battery door open and then closed with the batteries inserted.", + "inclusion": "ADD\n1. Slide battery door open.\n2. Insert batteries checking the correct orientation.\n3. Press and hold the test button. Keep it held down as you slide the battery drawer closed. You may then release the button.\nNOTE: Use only your finger or thumb on the test button. The use of any other instrument is strictly prohibited", + "exclusion": "REMOVE\n1. Slide battery door open.\n2. Remove and re-insert batteries checking the correct orientation.\n3. Press and hold the test button. Keep it held down as you slide the battery drawer closed. You may then release the button.\nNOTE: Use only your finger or thumb on the test button. The use of any other instrument is strictly prohibited", + "reset": "RESET DEVICE\nIf the device is powered up with the test button held down for 10+ seconds, the device will reset all Z-Wave settings and leave the network.\nUpon completion of the Reset operation, the LED will glow and the horn will sound for ~1 second.\nPlease use this procedure only when the network primary controller is missing or otherwise inoperable", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=product_documents/3886/User_Manual_M08-0456-173833_D2.pdf" + } + }, + "label": "ZCOMBO", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 6, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0138:0x0001:0x0003:11.0.0", + "statistics": { + "commandsTX": 1, + "commandsRX": 4, + "commandsDroppedRX": 1, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -79, + "repeaterRSSI": [] + }, + "lastSeen": "2024-11-11T21:36:45.802Z", + "rtt": 28.9, + "rssi": -79 + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2024-11-11T19:17:39.916Z", + "protocol": 0, + "values": [ + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Supervision Report Timeout", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "ZCOMBO will send the message over Supervision Command Class and it will wait for the Supervision report from the Controller for the Supervision report timeout time.", + "label": "Supervision Report Timeout", + "default": 1500, + "min": 500, + "max": 5000, + "unit": "ms", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1500 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Supervision Retry Count", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "If the Supervision report is not received within the Supervision report timeout time, the ZCOMBO will retry sending the message again. Upon exceeding the max retry, the ZCOMBO device will send the next message available in the queue.", + "label": "Supervision Retry Count", + "default": 1, + "min": 0, + "max": 5, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Supervision Wait Time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Before retrying the message, ZCOMBO will wait for the Supervision wait time. Actual wait time is calculated using the formula: Wait Time = Supervision wait time base-value + random-value + (attempt-count x 5 seconds). The random value will be between 100 and 1100 milliseconds.", + "label": "Supervision Wait Time", + "default": 5, + "min": 1, + "max": 60, + "unit": "seconds", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Smoke Alarm", + "propertyKey": "Sensor status", + "propertyName": "Smoke Alarm", + "propertyKeyName": "Sensor status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Sensor status", + "ccSpecific": { + "notificationType": 1 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Smoke detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Smoke Alarm", + "propertyKey": "Alarm status", + "propertyName": "Smoke Alarm", + "propertyKeyName": "Alarm status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm status", + "ccSpecific": { + "notificationType": 1 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "3": "Smoke alarm test", + "6": "Alarm silenced" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "CO Alarm", + "propertyKey": "Sensor status", + "propertyName": "CO Alarm", + "propertyKeyName": "Sensor status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Sensor status", + "ccSpecific": { + "notificationType": 2 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Carbon monoxide detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "CO Alarm", + "propertyKey": "Maintenance status", + "propertyName": "CO Alarm", + "propertyKeyName": "Maintenance status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Maintenance status", + "ccSpecific": { + "notificationType": 2 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "5": "Replacement required, End-of-life" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "CO Alarm", + "propertyKey": "Alarm status", + "propertyName": "CO Alarm", + "propertyKeyName": "Alarm status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm status", + "ccSpecific": { + "notificationType": 2 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Alarm silenced" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "System", + "propertyKey": "Hardware status", + "propertyName": "System", + "propertyKeyName": "Hardware status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Hardware status", + "ccSpecific": { + "notificationType": 9 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "System hardware failure" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 312 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 92 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "wakeUpInterval", + "propertyName": "wakeUpInterval", + "ccVersion": 2, + "metadata": { + "type": "number", + "default": 4200, + "readable": false, + "writeable": true, + "min": 4200, + "max": 4200, + "steps": 0, + "stateful": true, + "secret": false + }, + "value": 4200 + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "controllerNodeId", + "propertyName": "controllerNodeId", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Node ID of the controller", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.7" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["11.0", "7.0"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "6.81.6" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "4.3.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 52445 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.7.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 97 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "11.0.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 0 + } + ], + "endpoints": [ + { + "nodeId": 3, + "index": 0, + "installerIcon": 3073, + "userIcon": 3073, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + } + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": true + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 132, + "name": "Wake Up", + "version": 2, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index f0134c7c43c..2e3d8fd290a 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5,7 +5,7 @@ from http import HTTPStatus from io import BytesIO import json from typing import Any -from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +from unittest.mock import AsyncMock, MagicMock, PropertyMock, call, patch import pytest from zwave_js_server.const import ( @@ -39,10 +39,12 @@ from zwave_js_server.model.value import ConfigurationValue, get_value_id_str from homeassistant.components.websocket_api import ERR_INVALID_FORMAT, ERR_NOT_FOUND from homeassistant.components.zwave_js.api import ( APPLICATION_VERSION, + AREA_ID, CLIENT_SIDE_AUTH, COMMAND_CLASS_ID, CONFIG, DEVICE_ID, + DEVICE_NAME, DSK, ENABLED, ENDPOINT, @@ -67,6 +69,7 @@ from homeassistant.components.zwave_js.api import ( PRODUCT_TYPE, PROPERTY, PROPERTY_KEY, + PROTOCOL, QR_CODE_STRING, QR_PROVISIONING_INFORMATION, REQUESTED_SECURITY_CLASSES, @@ -485,14 +488,14 @@ async def test_node_alerts( hass_ws_client: WebSocketGenerator, ) -> None: """Test the node comments websocket command.""" + entry = integration ws_client = await hass_ws_client(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, "3245146787-35")}) assert device - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 3, TYPE: "zwave_js/node_alerts", DEVICE_ID: device.id, } @@ -502,6 +505,83 @@ async def test_node_alerts( assert result["comments"] == [{"level": "info", "text": "test"}] assert result["is_embedded"] + # Test with provisioned device + valid_qr_info = { + VERSION: 1, + SECURITY_CLASSES: [0], + DSK: "test", + GENERIC_DEVICE_CLASS: 1, + SPECIFIC_DEVICE_CLASS: 1, + INSTALLER_ICON_TYPE: 1, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 1, + PRODUCT_ID: 1, + APPLICATION_VERSION: "test", + } + + # Test QR provisioning information + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/provision_smart_start_node", + ENTRY_ID: entry.entry_id, + QR_PROVISIONING_INFORMATION: valid_qr_info, + DEVICE_NAME: "test", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + with patch( + f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entries", + return_value=[ + ProvisioningEntry.from_dict({**valid_qr_info, "device_id": msg["result"]}) + ], + ): + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/node_alerts", + DEVICE_ID: msg["result"], + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"]["comments"] == [ + { + "level": "info", + "text": "This device has been provisioned but is not yet included in the network.", + } + ] + + # Test missing node with no provisioning entry + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "3245146787-12")}, + ) + assert device + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/node_alerts", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + + # Test integration not loaded error - need to unload the integration + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/node_alerts", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + async def test_add_node( hass: HomeAssistant, @@ -1093,7 +1173,11 @@ async def test_validate_dsk_and_enter_pin( async def test_provision_smart_start_node( - hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + integration, + client, + hass_ws_client: WebSocketGenerator, ) -> None: """Test provision_smart_start_node websocket command.""" entry = integration @@ -1131,20 +1215,9 @@ async def test_provision_smart_start_node( assert len(client.async_send_command.call_args_list) == 1 assert client.async_send_command.call_args[0][0] == { "command": "controller.provision_smart_start_node", - "entry": QRProvisioningInformation( - version=QRCodeVersion.SMART_START, - security_classes=[SecurityClass.S2_UNAUTHENTICATED], + "entry": ProvisioningEntry( dsk="test", - generic_device_class=1, - specific_device_class=1, - installer_icon_type=1, - manufacturer_id=1, - product_type=1, - product_id=1, - application_version="test", - max_inclusion_request_interval=None, - uuid=None, - supported_protocols=None, + security_classes=[SecurityClass.S2_UNAUTHENTICATED], additional_properties={"name": "test"}, ).to_dict(), } @@ -1152,6 +1225,51 @@ async def test_provision_smart_start_node( client.async_send_command.reset_mock() client.async_send_command.return_value = {"success": True} + # Test QR provisioning information with device name and area + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/provision_smart_start_node", + ENTRY_ID: entry.entry_id, + QR_PROVISIONING_INFORMATION: { + **valid_qr_info, + }, + PROTOCOL: Protocols.ZWAVE_LONG_RANGE, + DEVICE_NAME: "test_name", + AREA_ID: "test_area", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + # verify a device was created + device = device_registry.async_get_device( + identifiers={(DOMAIN, "provision_test")}, + ) + assert device is not None + assert device.name == "test_name" + assert device.area_id == "test_area" + + assert len(client.async_send_command.call_args_list) == 2 + assert client.async_send_command.call_args_list[0][0][0] == { + "command": "config_manager.lookup_device", + "manufacturerId": 1, + "productType": 1, + "productId": 1, + } + assert client.async_send_command.call_args_list[1][0][0] == { + "command": "controller.provision_smart_start_node", + "entry": ProvisioningEntry( + dsk="test", + security_classes=[SecurityClass.S2_UNAUTHENTICATED], + protocol=Protocols.ZWAVE_LONG_RANGE, + additional_properties={ + "name": "test", + "device_id": device.id, + }, + ).to_dict(), + } + # Test QR provisioning information with S2 version throws error await ws_client.send_json( { @@ -1230,7 +1348,11 @@ async def test_provision_smart_start_node( async def test_unprovision_smart_start_node( - hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + integration, + client, + hass_ws_client: WebSocketGenerator, ) -> None: """Test unprovision_smart_start_node websocket command.""" entry = integration @@ -1239,9 +1361,8 @@ async def test_unprovision_smart_start_node( client.async_send_command.return_value = {} # Test node ID as input - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 1, TYPE: "zwave_js/unprovision_smart_start_node", ENTRY_ID: entry.entry_id, NODE_ID: 1, @@ -1251,8 +1372,12 @@ async def test_unprovision_smart_start_node( msg = await ws_client.receive_json() assert msg["success"] - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { + assert len(client.async_send_command.call_args_list) == 2 + assert client.async_send_command.call_args_list[0][0][0] == { + "command": "controller.get_provisioning_entry", + "dskOrNodeId": 1, + } + assert client.async_send_command.call_args_list[1][0][0] == { "command": "controller.unprovision_smart_start_node", "dskOrNodeId": 1, } @@ -1261,9 +1386,8 @@ async def test_unprovision_smart_start_node( client.async_send_command.return_value = {} # Test DSK as input - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 2, TYPE: "zwave_js/unprovision_smart_start_node", ENTRY_ID: entry.entry_id, DSK: "test", @@ -1273,8 +1397,12 @@ async def test_unprovision_smart_start_node( msg = await ws_client.receive_json() assert msg["success"] - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { + assert len(client.async_send_command.call_args_list) == 2 + assert client.async_send_command.call_args_list[0][0][0] == { + "command": "controller.get_provisioning_entry", + "dskOrNodeId": "test", + } + assert client.async_send_command.call_args_list[1][0][0] == { "command": "controller.unprovision_smart_start_node", "dskOrNodeId": "test", } @@ -1283,9 +1411,8 @@ async def test_unprovision_smart_start_node( client.async_send_command.return_value = {} # Test not including DSK or node ID as input fails - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 3, TYPE: "zwave_js/unprovision_smart_start_node", ENTRY_ID: entry.entry_id, } @@ -1296,14 +1423,78 @@ async def test_unprovision_smart_start_node( assert len(client.async_send_command.call_args_list) == 0 + # Test with pre provisioned device + # Create device registry entry for mock node + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "provision_test"), ("other_domain", "test")}, + name="Node 67", + ) + provisioning_entry = ProvisioningEntry.from_dict( + { + "dsk": "test", + "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], + "device_id": device.id, + } + ) + with patch.object( + client.driver.controller, + "async_get_provisioning_entry", + return_value=provisioning_entry, + ): + # Don't remove the device if it has additional identifiers + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/unprovision_smart_start_node", + ENTRY_ID: entry.entry_id, + DSK: "test", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.unprovision_smart_start_node", + "dskOrNodeId": "test", + } + + device = device_registry.async_get(device.id) + assert device is not None + + client.async_send_command.reset_mock() + + # Remove the device if it doesn't have additional identifiers + device_registry.async_update_device( + device.id, new_identifiers={(DOMAIN, "provision_test")} + ) + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/unprovision_smart_start_node", + ENTRY_ID: entry.entry_id, + DSK: "test", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.unprovision_smart_start_node", + "dskOrNodeId": "test", + } + + # Verify device was removed from device registry + device = device_registry.async_get(device.id) + assert device is None + # Test FailedZWaveCommand is caught with patch( f"{CONTROLLER_PATCH_PREFIX}.async_unprovision_smart_start_node", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 6, TYPE: "zwave_js/unprovision_smart_start_node", ENTRY_ID: entry.entry_id, DSK: "test", @@ -1319,9 +1510,8 @@ async def test_unprovision_smart_start_node( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 7, TYPE: "zwave_js/unprovision_smart_start_node", ENTRY_ID: entry.entry_id, DSK: "test", @@ -4888,53 +5078,97 @@ async def test_subscribe_node_statistics( assert msg["error"]["code"] == ERR_NOT_LOADED -@pytest.mark.skip( - reason="The test needs to be updated to reflect what happens when resetting the controller" -) async def test_hard_reset_controller( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - client, - integration, - listen_block, + client: MagicMock, + integration: MockConfigEntry, hass_ws_client: WebSocketGenerator, ) -> None: """Test that the hard_reset_controller WS API call works.""" entry = integration ws_client = await hass_ws_client(hass) - device = device_registry.async_get_device( - identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} - ) + async def async_send_command_driver_ready( + message: dict[str, Any], + require_schema: int | None = None, + ) -> dict: + """Send a command and get a response.""" + client.driver.emit( + "driver ready", {"event": "driver ready", "source": "driver"} + ) + return {} - client.async_send_command.return_value = {} - await ws_client.send_json( + client.async_send_command.side_effect = async_send_command_driver_ready + + await ws_client.send_json_auto_id( { - ID: 1, TYPE: "zwave_js/hard_reset_controller", ENTRY_ID: entry.entry_id, } ) - - listen_block.set() - listen_block.clear() - await hass.async_block_till_done() - msg = await ws_client.receive_json() + + device = device_registry.async_get_device( + identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} + ) + assert device is not None assert msg["result"] == device.id assert msg["success"] - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == {"command": "driver.hard_reset"} + assert client.async_send_command.call_count == 3 + # The first call is the relevant hard reset command. + # 25 is the require_schema parameter. + assert client.async_send_command.call_args_list[0] == call( + {"command": "driver.hard_reset"}, 25 + ) + + client.async_send_command.reset_mock() + + # Test sending command with driver not ready and timeout. + + async def async_send_command_no_driver_ready( + message: dict[str, Any], + require_schema: int | None = None, + ) -> dict: + """Send a command and get a response.""" + return {} + + client.async_send_command.side_effect = async_send_command_no_driver_ready + + with patch( + "homeassistant.components.zwave_js.api.HARD_RESET_CONTROLLER_DRIVER_READY_TIMEOUT", + new=0, + ): + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/hard_reset_controller", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + device = device_registry.async_get_device( + identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} + ) + assert device is not None + assert msg["result"] == device.id + assert msg["success"] + + assert client.async_send_command.call_count == 3 + # The first call is the relevant hard reset command. + # 25 is the require_schema parameter. + assert client.async_send_command.call_args_list[0] == call( + {"command": "driver.hard_reset"}, 25 + ) # Test FailedZWaveCommand is caught with patch( "zwave_js_server.model.driver.Driver.async_hard_reset", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 2, TYPE: "zwave_js/hard_reset_controller", ENTRY_ID: entry.entry_id, } @@ -4949,9 +5183,8 @@ async def test_hard_reset_controller( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 3, TYPE: "zwave_js/hard_reset_controller", ENTRY_ID: entry.entry_id, } @@ -4961,9 +5194,8 @@ async def test_hard_reset_controller( assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_LOADED - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 4, TYPE: "zwave_js/hard_reset_controller", ENTRY_ID: "INVALID", } @@ -5658,3 +5890,39 @@ async def test_lookup_device( assert not msg["success"] assert msg["error"]["code"] == error_message assert msg["error"]["message"] == f"Command failed: {error_message}" + + +async def test_subscribe_new_devices( + hass: HomeAssistant, + integration, + client, + hass_ws_client: WebSocketGenerator, + multisensor_6_state, +) -> None: + """Test the subscribe_new_devices websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/subscribe_new_devices", + ENTRY_ID: entry.entry_id, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] is None + + # Simulate a device being registered + node = Node(client, deepcopy(multisensor_6_state)) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + + # Verify we receive the expected message + msg = await ws_client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["event"] == "device registered" + assert msg["event"]["device"]["name"] == node.device_config.description + assert msg["event"]["device"]["manufacturer"] == node.device_config.manufacturer + assert msg["event"]["device"]["model"] == node.device_config.label diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 657dd337bf9..93ac52f9041 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -293,3 +293,141 @@ async def test_config_parameter_binary_sensor( state = hass.states.get(binary_sensor_entity_id) assert state assert state.state == STATE_OFF + + +async def test_smoke_co_notification_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zcombo_smoke_co_alarm: Node, + integration: MockConfigEntry, +) -> None: + """Test smoke and CO notification sensors with diagnostic states.""" + # Test smoke alarm sensor + smoke_sensor = "binary_sensor.zcombo_g_smoke_co_alarm_smoke_detected" + state = hass.states.get(smoke_sensor) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.SMOKE + entity_entry = entity_registry.async_get(smoke_sensor) + assert entity_entry + assert entity_entry.entity_category != EntityCategory.DIAGNOSTIC + + # Test smoke alarm diagnostic sensor + smoke_diagnostic = "binary_sensor.zcombo_g_smoke_co_alarm_smoke_alarm_test" + state = hass.states.get(smoke_diagnostic) + assert state + assert state.state == STATE_OFF + entity_entry = entity_registry.async_get(smoke_diagnostic) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + + # Test CO alarm sensor + co_sensor = "binary_sensor.zcombo_g_smoke_co_alarm_carbon_monoxide_detected" + state = hass.states.get(co_sensor) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.CO + entity_entry = entity_registry.async_get(co_sensor) + assert entity_entry + assert entity_entry.entity_category != EntityCategory.DIAGNOSTIC + + # Test diagnostic entities + entity_ids = [ + "binary_sensor.zcombo_g_smoke_co_alarm_smoke_alarm_test", + "binary_sensor.zcombo_g_smoke_co_alarm_alarm_silenced", + "binary_sensor.zcombo_g_smoke_co_alarm_replacement_required_end_of_life", + "binary_sensor.zcombo_g_smoke_co_alarm_alarm_silenced_2", + "binary_sensor.zcombo_g_smoke_co_alarm_system_hardware_failure", + "binary_sensor.zcombo_g_smoke_co_alarm_low_battery_level", + ] + for entity_id in entity_ids: + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + + # Test state updates for smoke alarm + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 3, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Smoke Alarm", + "propertyKey": "Sensor status", + "newValue": 2, + "prevValue": 0, + "propertyName": "Smoke Alarm", + "propertyKeyName": "Sensor status", + }, + }, + ) + zcombo_smoke_co_alarm.receive_event(event) + await hass.async_block_till_done() # Wait for state change to be processed + # Get a fresh state after the sleep + state = hass.states.get(smoke_sensor) + assert state is not None, "Smoke sensor state should not be None" + assert state.state == STATE_ON, ( + f"Expected smoke sensor state to be 'on', got '{state.state}'" + ) + + # Test state updates for CO alarm + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 3, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "CO Alarm", + "propertyKey": "Sensor status", + "newValue": 2, + "prevValue": 0, + "propertyName": "CO Alarm", + "propertyKeyName": "Sensor status", + }, + }, + ) + zcombo_smoke_co_alarm.receive_event(event) + await hass.async_block_till_done() # Wait for state change to be processed + # Get a fresh state after the sleep + state = hass.states.get(co_sensor) + assert state is not None, "CO sensor state should not be None" + assert state.state == STATE_ON, ( + f"Expected CO sensor state to be 'on', got '{state.state}'" + ) + + # Test diagnostic state updates for smoke alarm + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 3, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Smoke Alarm", + "propertyKey": "Alarm status", + "newValue": 3, + "prevValue": 0, + "propertyName": "Smoke Alarm", + "propertyKeyName": "Alarm status", + }, + }, + ) + zcombo_smoke_co_alarm.receive_event(event) + await hass.async_block_till_done() # Wait for state change to be processed + # Get a fresh state after the sleep + state = hass.states.get(smoke_diagnostic) + assert state is not None, "Smoke diagnostic state should not be None" + assert state.state == STATE_ON, ( + f"Expected smoke diagnostic state to be 'on', got '{state.state}'" + ) diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index e7239c23de6..15fd9fcbd30 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -13,18 +13,19 @@ from aiohasupervisor.models import AddonsOptions, Discovery import aiohttp import pytest from serial.tools.list_ports_common import ListPortInfo +from zwave_js_server.exceptions import FailedCommand from zwave_js_server.version import VersionInfo -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.zwave_js.config_flow import SERVER_VERSION_TIMEOUT, TITLE -from homeassistant.components.zwave_js.const import ADDON_SLUG, DOMAIN +from homeassistant.components.zwave_js.const import ADDON_SLUG, CONF_USB_PATH, 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 +from tests.common import MockConfigEntry, async_capture_events ADDON_DISCOVERY_INFO = { "addon": "Z-Wave JS", @@ -70,6 +71,15 @@ def setup_entry_fixture() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture(name="unload_entry") +def unload_entry_fixture() -> Generator[AsyncMock]: + """Mock entry unload.""" + with patch( + "homeassistant.components.zwave_js.async_unload_entry", return_value=True + ) as mock_unload_entry: + yield mock_unload_entry + + @pytest.fixture(name="supervisor") def mock_supervisor_fixture() -> Generator[None]: """Mock Supervisor.""" @@ -170,6 +180,29 @@ def mock_usb_serial_by_id_fixture() -> Generator[MagicMock]: yield mock_usb_serial_by_id +@pytest.fixture +def mock_sdk_version(client: MagicMock) -> Generator[None]: + """Mock the SDK version of the controller.""" + original_sdk_version = client.driver.controller.data.get("sdkVersion") + client.driver.controller.data["sdkVersion"] = "6.60" + yield + if original_sdk_version is not None: + client.driver.controller.data["sdkVersion"] = original_sdk_version + + +@pytest.fixture(name="driver_ready_timeout") +def mock_driver_ready_timeout() -> Generator[None]: + """Mock migration nvm restore driver ready timeout.""" + with patch( + ( + "homeassistant.components.zwave_js.config_flow." + "RESTORE_NVM_DRIVER_READY_TIMEOUT" + ), + new=0, + ): + yield + + async def test_manual(hass: HomeAssistant) -> None: """Test we create an entry with manual step.""" @@ -220,18 +253,48 @@ async def slow_server_version(*args): @pytest.mark.parametrize( - ("flow", "flow_params"), + ("url", "server_version_side_effect", "server_version_timeout", "error"), [ ( - "flow", - lambda entry: { - "handler": DOMAIN, - "context": {"source": config_entries.SOURCE_USER}, - }, + "not-ws-url", + None, + SERVER_VERSION_TIMEOUT, + "invalid_ws_url", + ), + ( + "ws://localhost:3000", + slow_server_version, + 0, + "cannot_connect", + ), + ( + "ws://localhost:3000", + Exception("Boom"), + SERVER_VERSION_TIMEOUT, + "unknown", ), - ("options", lambda entry: {"handler": entry.entry_id}), ], ) +async def test_manual_errors(hass: HomeAssistant, integration, url, error) -> None: + """Test all errors with a manual set up.""" + 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"] == "manual" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": url, + }, + ) + + assert result["step_id"] == "manual" + assert result["errors"] == {"base": error} + + @pytest.mark.parametrize( ("url", "server_version_side_effect", "server_version_timeout", "error"), [ @@ -255,25 +318,30 @@ async def slow_server_version(*args): ), ], ) -async def test_manual_errors( - hass: HomeAssistant, integration, url, error, flow, flow_params +async def test_reconfigure_manual_errors( + hass: HomeAssistant, integration, url, error ) -> None: - """Test all errors with a manual set up.""" + """Test all errors with a manual set up in a reconfigure flow.""" entry = integration - result = await getattr(hass.config_entries, flow).async_init(**flow_params(entry)) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "manual" + assert result["step_id"] == "manual_reconfigure" - result = await getattr(hass.config_entries, flow).async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "url": url, }, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "manual" + assert result["step_id"] == "manual_reconfigure" assert result["errors"] == {"base": error} @@ -556,6 +624,28 @@ async def test_abort_hassio_discovery_for_other_addon( assert result2["reason"] == "not_zwave_js_addon" +@pytest.mark.parametrize( + ("usb_discovery_info", "device", "discovery_name"), + [ + ( + USB_DISCOVERY_INFO, + USB_DISCOVERY_INFO.device, + "zwave radio", + ), + ( + UsbServiceInfo( + device="/dev/zwa2", + pid="303A", + vid="4001", + serial_number="1234", + description="ZWA-2 - Nabu Casa ZWA-2", + manufacturer="Nabu Casa", + ), + "/dev/zwa2", + "Home Assistant Connect ZWA-2", + ), + ], +) @pytest.mark.parametrize( "discovery_info", [ @@ -576,17 +666,23 @@ async def test_usb_discovery( install_addon, addon_options, get_addon_discovery_info, + mock_usb_serial_by_id: MagicMock, set_addon_options, start_addon, + usb_discovery_info: UsbServiceInfo, + device: str, + discovery_name: str, ) -> None: """Test usb discovery success path.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USB}, - data=USB_DISCOVERY_INFO, + data=usb_discovery_info, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "usb_confirm" + assert result["description_placeholders"] == {"name": discovery_name} + assert mock_usb_serial_by_id.call_count == 1 result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -601,7 +697,7 @@ async def test_usb_discovery( assert install_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -619,7 +715,7 @@ async def test_usb_discovery( "core_zwave_js", AddonsOptions( config={ - "device": USB_DISCOVERY_INFO.device, + "device": device, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -652,7 +748,7 @@ async def test_usb_discovery( assert result["title"] == TITLE assert result["data"] == { "url": "ws://host1:3001", - "usb_path": USB_DISCOVERY_INFO.device, + "usb_path": device, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -684,6 +780,7 @@ async def test_usb_discovery_addon_not_running( supervisor, addon_installed, addon_options, + mock_usb_serial_by_id: MagicMock, set_addon_options, start_addon, get_addon_discovery_info, @@ -698,11 +795,12 @@ async def test_usb_discovery_addon_not_running( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "usb_confirm" + assert mock_usb_serial_by_id.call_count == 2 result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" # Make sure the discovered usb device is preferred. data_schema = result["data_schema"] @@ -778,6 +876,276 @@ async def test_usb_discovery_addon_not_running( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("supervisor", "addon_running", "get_addon_discovery_info") +@pytest.mark.parametrize( + "discovery_info", + [ + [ + Discovery( + addon="core_zwave_js", + service="zwave_js", + uuid=uuid4(), + config=ADDON_DISCOVERY_INFO, + ) + ] + ], +) +async def test_usb_discovery_migration( + hass: HomeAssistant, + addon_options: dict[str, Any], + mock_usb_serial_by_id: MagicMock, + set_addon_options: AsyncMock, + restart_addon: AsyncMock, + client: MagicMock, + integration: MockConfigEntry, +) -> None: + """Test usb discovery migration.""" + addon_options["device"] = "/dev/ttyUSB0" + entry = integration + assert client.connect.call_count == 1 + hass.config_entries.async_update_entry( + entry, + unique_id="1234", + data={ + "url": "ws://localhost:3000", + "use_addon": True, + "usb_path": "/dev/ttyUSB0", + }, + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm backup progress", {"bytesRead": 100, "total": 200} + ) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + + async def mock_restore_nvm(data: bytes): + client.driver.controller.emit( + "nvm convert progress", + {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, + ) + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm restore progress", + {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, + ) + client.driver.emit( + "driver ready", {"event": "driver ready", "source": "driver"} + ) + + client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) + + events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "usb_confirm" + assert mock_usb_serial_by_id.call_count == 2 + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + assert len(events) == 1 + assert events[0].data["progress"] == 0.5 + events.clear() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + assert set_addon_options.call_args == call( + "core_zwave_js", AddonsOptions(config={"device": USB_DISCOVERY_INFO.device}) + ) + + await hass.async_block_till_done() + + assert restart_addon.call_args == call("core_zwave_js") + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + assert client.connect.call_count == 2 + + await hass.async_block_till_done() + assert client.connect.call_count == 3 + assert entry.state is config_entries.ConfigEntryState.LOADED + assert client.driver.controller.async_restore_nvm.call_count == 1 + assert len(events) == 2 + assert events[0].data["progress"] == 0.25 + assert events[1].data["progress"] == 0.75 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migration_successful" + assert integration.data["url"] == "ws://host1:3001" + assert integration.data["usb_path"] == USB_DISCOVERY_INFO.device + assert integration.data["use_addon"] is True + + +@pytest.mark.usefixtures("supervisor", "addon_running", "get_addon_discovery_info") +@pytest.mark.parametrize( + "discovery_info", + [ + [ + Discovery( + addon="core_zwave_js", + service="zwave_js", + uuid=uuid4(), + config=ADDON_DISCOVERY_INFO, + ) + ] + ], +) +async def test_usb_discovery_migration_driver_ready_timeout( + hass: HomeAssistant, + addon_options: dict[str, Any], + driver_ready_timeout: None, + mock_usb_serial_by_id: MagicMock, + set_addon_options: AsyncMock, + restart_addon: AsyncMock, + client: MagicMock, + integration: MockConfigEntry, +) -> None: + """Test driver ready timeout after nvm restore during usb discovery migration.""" + addon_options["device"] = "/dev/ttyUSB0" + entry = integration + assert client.connect.call_count == 1 + hass.config_entries.async_update_entry( + entry, + unique_id="1234", + data={ + "url": "ws://localhost:3000", + "use_addon": True, + "usb_path": "/dev/ttyUSB0", + }, + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm backup progress", {"bytesRead": 100, "total": 200} + ) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + + async def mock_restore_nvm(data: bytes): + client.driver.controller.emit( + "nvm convert progress", + {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, + ) + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm restore progress", + {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, + ) + + client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) + + events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "usb_confirm" + assert mock_usb_serial_by_id.call_count == 2 + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + assert len(events) == 1 + assert events[0].data["progress"] == 0.5 + events.clear() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + assert set_addon_options.call_args == call( + "core_zwave_js", AddonsOptions(config={"device": USB_DISCOVERY_INFO.device}) + ) + + await hass.async_block_till_done() + + assert restart_addon.call_args == call("core_zwave_js") + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + assert client.connect.call_count == 2 + + await hass.async_block_till_done() + assert client.connect.call_count == 3 + assert entry.state is config_entries.ConfigEntryState.LOADED + assert client.driver.controller.async_restore_nvm.call_count == 1 + assert len(events) == 2 + assert events[0].data["progress"] == 0.25 + assert events[1].data["progress"] == 0.75 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migration_successful" + assert integration.data["url"] == "ws://host1:3001" + assert integration.data["usb_path"] == USB_DISCOVERY_INFO.device + assert integration.data["use_addon"] is True + + async def test_discovery_addon_not_running( hass: HomeAssistant, supervisor, @@ -806,7 +1174,7 @@ async def test_discovery_addon_not_running( result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -908,7 +1276,7 @@ async def test_discovery_addon_not_installed( assert install_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1001,10 +1369,50 @@ async def test_abort_usb_discovery_with_existing_flow( assert result2["reason"] == "already_in_progress" -async def test_abort_usb_discovery_already_configured( +@pytest.mark.usefixtures("supervisor", "addon_installed") +async def test_usb_discovery_with_existing_usb_flow(hass: HomeAssistant) -> None: + """Test usb discovery allows more than one USB flow in progress.""" + first_usb_info = UsbServiceInfo( + device="/dev/other_device", + pid="AAAA", + vid="AAAA", + serial_number="5678", + description="zwave radio", + manufacturer="test", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=first_usb_info, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "usb_confirm" + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "usb_confirm" + + usb_flows_in_progress = hass.config_entries.flow.async_progress_by_handler( + DOMAIN, match_context={"source": config_entries.SOURCE_USB} + ) + + assert len(usb_flows_in_progress) == 2 + + for flow in (result, result2): + hass.config_entries.flow.async_abort(flow["flow_id"]) + + assert len(hass.config_entries.flow.async_progress()) == 0 + + +async def test_abort_usb_discovery_addon_required( hass: HomeAssistant, supervisor, addon_options ) -> None: - """Test usb discovery flow is aborted when there is an existing entry.""" + """Test usb discovery aborted when existing entry not using add-on.""" entry = MockConfigEntry( domain=DOMAIN, data={"url": "ws://localhost:3000"}, @@ -1019,7 +1427,54 @@ async def test_abort_usb_discovery_already_configured( data=USB_DISCOVERY_INFO, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "addon_required" + + +@pytest.mark.usefixtures( + "supervisor", + "addon_running", +) +async def test_abort_usb_discovery_confirm_addon_required( + hass: HomeAssistant, + addon_options: dict[str, Any], + mock_usb_serial_by_id: MagicMock, +) -> None: + """Test usb discovery confirm aborted when existing entry not using add-on.""" + addon_options["device"] = "/dev/another_device" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "url": "ws://localhost:3000", + "usb_path": "/dev/another_device", + "use_addon": True, + }, + title=TITLE, + unique_id="1234", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "usb_confirm" + assert mock_usb_serial_by_id.call_count == 2 + + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + "use_addon": False, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_required" async def test_usb_discovery_requires_supervisor(hass: HomeAssistant) -> None: @@ -1033,10 +1488,14 @@ async def test_usb_discovery_requires_supervisor(hass: HomeAssistant) -> None: assert result["reason"] == "discovery_requires_supervisor" -async def test_usb_discovery_already_running( - hass: HomeAssistant, supervisor, addon_running +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_usb_discovery_same_device( + hass: HomeAssistant, + addon_options: dict[str, Any], + mock_usb_serial_by_id: MagicMock, ) -> None: - """Test usb discovery flow is aborted when the addon is running.""" + """Test usb discovery flow is aborted when the add-on device is discovered.""" + addon_options["device"] = USB_DISCOVERY_INFO.device result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USB}, @@ -1044,6 +1503,7 @@ async def test_usb_discovery_already_running( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + assert mock_usb_serial_by_id.call_count == 2 @pytest.mark.parametrize( @@ -1377,7 +1837,7 @@ async def test_addon_installed( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1480,7 +1940,7 @@ async def test_addon_installed_start_failure( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1565,7 +2025,7 @@ async def test_addon_installed_failures( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1646,7 +2106,7 @@ async def test_addon_installed_set_options_failure( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1682,6 +2142,32 @@ async def test_addon_installed_set_options_failure( assert start_addon.call_count == 0 +async def test_addon_installed_usb_ports_failure( + hass: HomeAssistant, + supervisor, + addon_installed, +) -> None: + """Test usb ports failure when add-on is installed.""" + + 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"] == "on_supervisor" + + with patch( + "homeassistant.components.zwave_js.config_flow.async_get_usb_ports", + side_effect=OSError("test_error"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "usb_ports_failed" + + @pytest.mark.parametrize( "discovery_info", [ @@ -1735,7 +2221,7 @@ async def test_addon_installed_already_configured( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1831,7 +2317,7 @@ async def test_addon_not_installed( assert install_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1927,25 +2413,33 @@ async def test_install_addon_failure( assert result["reason"] == "addon_install_failed" -async def test_options_manual(hass: HomeAssistant, client, integration) -> None: - """Test manual settings in options flow.""" +async def test_reconfigure_manual(hass: HomeAssistant, client, integration) -> None: + """Test manual settings in reconfigure flow.""" entry = integration hass.config_entries.async_update_entry(entry, unique_id="1234") assert client.connect.call_count == 1 assert client.disconnect.call_count == 0 - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "manual" + assert result["step_id"] == "manual_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"url": "ws://1.1.1.1:3001"} ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert entry.data["url"] == "ws://1.1.1.1:3001" assert entry.data["use_addon"] is False assert entry.data["integration_created_addon"] is False @@ -1953,19 +2447,26 @@ async def test_options_manual(hass: HomeAssistant, client, integration) -> None: assert client.disconnect.call_count == 1 -async def test_options_manual_different_device( +async def test_reconfigure_manual_different_device( hass: HomeAssistant, integration ) -> None: - """Test options flow manual step connecting to different device.""" + """Test reconfigure flow manual step connecting to different device.""" entry = integration hass.config_entries.async_update_entry(entry, unique_id="5678") - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "manual" + assert result["step_id"] == "manual_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"url": "ws://1.1.1.1:3001"} ) await hass.async_block_till_done() @@ -1974,29 +2475,36 @@ async def test_options_manual_different_device( assert result["reason"] == "different_device" -async def test_options_not_addon( +async def test_reconfigure_not_addon( hass: HomeAssistant, client, supervisor, integration ) -> None: - """Test options flow and opting out of add-on on Supervisor.""" + """Test reconfigure flow and opting out of add-on on Supervisor.""" entry = integration hass.config_entries.async_update_entry(entry, unique_id="1234") assert client.connect.call_count == 1 assert client.disconnect.call_count == 0 - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "on_supervisor" + assert result["step_id"] == "on_supervisor_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"use_addon": False} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "manual" + assert result["step_id"] == "manual_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "url": "ws://localhost:3000", @@ -2004,7 +2512,8 @@ async def test_options_not_addon( ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert entry.data["url"] == "ws://localhost:3000" assert entry.data["use_addon"] is False assert entry.data["integration_created_addon"] is False @@ -2012,6 +2521,127 @@ async def test_options_not_addon( assert client.disconnect.call_count == 1 +@pytest.mark.usefixtures("supervisor") +async def test_reconfigure_not_addon_with_addon( + hass: HomeAssistant, + setup_entry: AsyncMock, + unload_entry: AsyncMock, + integration: MockConfigEntry, + stop_addon: AsyncMock, +) -> None: + """Test reconfigure flow opting out of add-on on Supervisor with add-on.""" + entry = integration + hass.config_entries.async_update_entry( + entry, + data={**entry.data, "url": "ws://host1:3001", "use_addon": True}, + unique_id="1234", + ) + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert unload_entry.call_count == 0 + setup_entry.reset_mock() + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "on_supervisor_reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": False} + ) + + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + assert setup_entry.call_count == 0 + assert unload_entry.call_count == 1 + assert stop_addon.call_count == 1 + assert stop_addon.call_args == call("core_zwave_js") + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": "ws://localhost:3000", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data["url"] == "ws://localhost:3000" + assert entry.data["use_addon"] is False + assert entry.data["integration_created_addon"] is False + assert entry.state is config_entries.ConfigEntryState.LOADED + assert setup_entry.call_count == 1 + assert unload_entry.call_count == 1 + + # avoid unload entry in teardown + await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("supervisor") +async def test_reconfigure_not_addon_with_addon_stop_fail( + hass: HomeAssistant, + setup_entry: AsyncMock, + unload_entry: AsyncMock, + integration: MockConfigEntry, + stop_addon: AsyncMock, +) -> None: + """Test reconfigure flow opting out of add-on and add-on stop error.""" + stop_addon.side_effect = SupervisorError("Boom!") + entry = integration + hass.config_entries.async_update_entry( + entry, + data={**entry.data, "url": "ws://host1:3001", "use_addon": True}, + unique_id="1234", + ) + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert unload_entry.call_count == 0 + setup_entry.reset_mock() + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "on_supervisor_reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": False} + ) + await hass.async_block_till_done() + + assert stop_addon.call_count == 1 + assert stop_addon.call_args == call("core_zwave_js") + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_stop_failed" + assert entry.data["url"] == "ws://host1:3001" + assert entry.data["use_addon"] is True + assert entry.state is config_entries.ConfigEntryState.LOADED + assert setup_entry.call_count == 1 + assert unload_entry.call_count == 1 + + # avoid unload entry in teardown + await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + @pytest.mark.parametrize( ( "discovery_info", @@ -2089,7 +2719,7 @@ async def test_options_not_addon( ), ], ) -async def test_options_addon_running( +async def test_reconfigure_addon_running( hass: HomeAssistant, client, supervisor, @@ -2105,7 +2735,7 @@ async def test_options_addon_running( new_addon_options, disconnect_calls, ) -> None: - """Test options flow and add-on already running on Supervisor.""" + """Test reconfigure flow and add-on already running on Supervisor.""" addon_options.update(old_addon_options) entry = integration data = {**entry.data, **entry_data} @@ -2116,19 +2746,26 @@ async def test_options_addon_running( assert client.connect.call_count == 1 assert client.disconnect.call_count == 0 - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "on_supervisor" + assert result["step_id"] == "on_supervisor_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"use_addon": True} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], new_addon_options, ) @@ -2144,12 +2781,13 @@ async def test_options_addon_running( assert result["step_id"] == "start_addon" await hass.async_block_till_done() - result = await hass.config_entries.options.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert restart_addon.call_args == call("core_zwave_js") - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == new_addon_options["device"] assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] @@ -2217,7 +2855,7 @@ async def test_options_addon_running( ), ], ) -async def test_options_addon_running_no_changes( +async def test_reconfigure_addon_running_no_changes( hass: HomeAssistant, client, supervisor, @@ -2232,7 +2870,7 @@ async def test_options_addon_running_no_changes( old_addon_options, new_addon_options, ) -> None: - """Test options flow without changes, and add-on already running on Supervisor.""" + """Test reconfigure flow without changes, and add-on already running on Supervisor.""" addon_options.update(old_addon_options) entry = integration data = {**entry.data, **entry_data} @@ -2243,19 +2881,26 @@ async def test_options_addon_running_no_changes( assert client.connect.call_count == 1 assert client.disconnect.call_count == 0 - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "on_supervisor" + assert result["step_id"] == "on_supervisor_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"use_addon": True} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], new_addon_options, ) @@ -2265,7 +2910,8 @@ async def test_options_addon_running_no_changes( assert set_addon_options.call_count == 0 assert restart_addon.call_count == 0 - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == new_addon_options["device"] assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] @@ -2388,7 +3034,7 @@ async def different_device_server_version(*args): ), ], ) -async def test_options_different_device( +async def test_reconfigure_different_device( hass: HomeAssistant, client, supervisor, @@ -2405,7 +3051,7 @@ async def test_options_different_device( disconnect_calls, server_version_side_effect, ) -> None: - """Test options flow and configuring a different device.""" + """Test reconfigure flow and configuring a different device.""" addon_options.update(old_addon_options) entry = integration data = {**entry.data, **entry_data} @@ -2416,19 +3062,26 @@ async def test_options_different_device( assert client.connect.call_count == 1 assert client.disconnect.call_count == 0 - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "on_supervisor" + assert result["step_id"] == "on_supervisor_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"use_addon": True} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], new_addon_options, ) @@ -2447,7 +3100,7 @@ async def test_options_different_device( assert restart_addon.call_count == 1 assert restart_addon.call_args == call("core_zwave_js") - result = await hass.config_entries.options.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() # Default emulate_hardware is False. @@ -2467,7 +3120,7 @@ async def test_options_different_device( assert restart_addon.call_count == 2 assert restart_addon.call_args == call("core_zwave_js") - result = await hass.config_entries.options.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT @@ -2564,7 +3217,7 @@ async def test_options_different_device( ), ], ) -async def test_options_addon_restart_failed( +async def test_reconfigure_addon_restart_failed( hass: HomeAssistant, client, supervisor, @@ -2581,7 +3234,7 @@ async def test_options_addon_restart_failed( disconnect_calls, restart_addon_side_effect, ) -> None: - """Test options flow and add-on restart failure.""" + """Test reconfigure flow and add-on restart failure.""" addon_options.update(old_addon_options) entry = integration data = {**entry.data, **entry_data} @@ -2592,19 +3245,26 @@ async def test_options_addon_restart_failed( assert client.connect.call_count == 1 assert client.disconnect.call_count == 0 - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "on_supervisor" + assert result["step_id"] == "on_supervisor_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"use_addon": True} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], new_addon_options, ) @@ -2623,7 +3283,7 @@ async def test_options_addon_restart_failed( assert restart_addon.call_count == 1 assert restart_addon.call_args == call("core_zwave_js") - result = await hass.config_entries.options.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() # The legacy network key should not be reset. @@ -2640,7 +3300,7 @@ async def test_options_addon_restart_failed( assert restart_addon.call_count == 2 assert restart_addon.call_args == call("core_zwave_js") - result = await hass.config_entries.options.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT @@ -2698,7 +3358,7 @@ async def test_options_addon_restart_failed( ), ], ) -async def test_options_addon_running_server_info_failure( +async def test_reconfigure_addon_running_server_info_failure( hass: HomeAssistant, client, supervisor, @@ -2715,7 +3375,7 @@ async def test_options_addon_running_server_info_failure( disconnect_calls, server_version_side_effect, ) -> None: - """Test options flow and add-on already running with server info failure.""" + """Test reconfigure flow and add-on already running with server info failure.""" addon_options.update(old_addon_options) entry = integration data = {**entry.data, **entry_data} @@ -2726,19 +3386,26 @@ async def test_options_addon_running_server_info_failure( assert client.connect.call_count == 1 assert client.disconnect.call_count == 0 - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "on_supervisor" + assert result["step_id"] == "on_supervisor_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"use_addon": True} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], new_addon_options, ) @@ -2828,7 +3495,7 @@ async def test_options_addon_running_server_info_failure( ), ], ) -async def test_options_addon_not_installed( +async def test_reconfigure_addon_not_installed( hass: HomeAssistant, client, supervisor, @@ -2845,7 +3512,7 @@ async def test_options_addon_not_installed( new_addon_options, disconnect_calls, ) -> None: - """Test options flow and add-on not installed on Supervisor.""" + """Test reconfigure flow and add-on not installed on Supervisor.""" addon_options.update(old_addon_options) entry = integration data = {**entry.data, **entry_data} @@ -2856,12 +3523,19 @@ async def test_options_addon_not_installed( assert client.connect.call_count == 1 assert client.disconnect.call_count == 0 - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "on_supervisor" + assert result["step_id"] == "on_supervisor_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"use_addon": True} ) @@ -2871,14 +3545,14 @@ async def test_options_addon_not_installed( # Make sure the flow continues when the progress task is done. await hass.async_block_till_done() - result = await hass.config_entries.options.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert install_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], new_addon_options, ) @@ -2897,11 +3571,12 @@ async def test_options_addon_not_installed( assert start_addon.call_count == 1 assert start_addon.call_args == call("core_zwave_js") - result = await hass.config_entries.options.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == new_addon_options["device"] assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] @@ -2959,3 +3634,775 @@ async def test_zeroconf(hass: HomeAssistant) -> None: } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure_migrate_no_addon(hass: HomeAssistant, integration) -> None: + """Test migration flow fails when not using add-on.""" + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="1234", data={**entry.data, "use_addon": False} + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_required" + + +@pytest.mark.usefixtures("mock_sdk_version") +async def test_reconfigure_migrate_low_sdk_version( + hass: HomeAssistant, + integration: MockConfigEntry, +) -> None: + """Test migration flow fails with too low controller SDK version.""" + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="1234", data={**entry.data, "use_addon": True} + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migration_low_sdk_version" + + +@pytest.mark.parametrize( + "discovery_info", + [ + [ + Discovery( + addon="core_zwave_js", + service="zwave_js", + uuid=uuid4(), + config=ADDON_DISCOVERY_INFO, + ) + ] + ], +) +async def test_reconfigure_migrate_with_addon( + hass: HomeAssistant, + client, + supervisor, + integration, + addon_running, + restart_addon, + set_addon_options, + get_addon_discovery_info, +) -> None: + """Test migration flow with add-on.""" + entry = integration + assert client.connect.call_count == 1 + hass.config_entries.async_update_entry( + entry, + unique_id="1234", + data={ + "url": "ws://localhost:3000", + "use_addon": True, + "usb_path": "/dev/ttyUSB0", + }, + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm backup progress", {"bytesRead": 100, "total": 200} + ) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + + async def mock_restore_nvm(data: bytes): + client.driver.controller.emit( + "nvm convert progress", + {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, + ) + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm restore progress", + {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, + ) + client.driver.emit( + "driver ready", {"event": "driver ready", "source": "driver"} + ) + + client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) + + events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + assert len(events) == 1 + assert events[0].data["progress"] == 0.5 + events.clear() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "choose_serial_port" + assert result["data_schema"].schema[CONF_USB_PATH] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USB_PATH: "/test", + }, + ) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + assert set_addon_options.call_args == call( + "core_zwave_js", AddonsOptions(config={"device": "/test"}) + ) + + await hass.async_block_till_done() + + assert restart_addon.call_args == call("core_zwave_js") + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + assert client.connect.call_count == 2 + + await hass.async_block_till_done() + assert client.connect.call_count == 3 + assert entry.state is config_entries.ConfigEntryState.LOADED + assert client.driver.controller.async_restore_nvm.call_count == 1 + assert len(events) == 2 + assert events[0].data["progress"] == 0.25 + assert events[1].data["progress"] == 0.75 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migration_successful" + assert integration.data["url"] == "ws://host1:3001" + assert integration.data["usb_path"] == "/test" + assert integration.data["use_addon"] is True + + +@pytest.mark.parametrize( + "discovery_info", + [ + [ + Discovery( + addon="core_zwave_js", + service="zwave_js", + uuid=uuid4(), + config=ADDON_DISCOVERY_INFO, + ) + ] + ], +) +async def test_reconfigure_migrate_driver_ready_timeout( + hass: HomeAssistant, + client, + supervisor, + integration, + addon_running, + driver_ready_timeout: None, + restart_addon, + set_addon_options, + get_addon_discovery_info, +) -> None: + """Test migration flow with driver ready timeout after nvm restore.""" + entry = integration + assert client.connect.call_count == 1 + hass.config_entries.async_update_entry( + entry, + unique_id="1234", + data={ + "url": "ws://localhost:3000", + "use_addon": True, + "usb_path": "/dev/ttyUSB0", + }, + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm backup progress", {"bytesRead": 100, "total": 200} + ) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + + async def mock_restore_nvm(data: bytes): + client.driver.controller.emit( + "nvm convert progress", + {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, + ) + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm restore progress", + {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, + ) + + client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) + + events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + assert len(events) == 1 + assert events[0].data["progress"] == 0.5 + events.clear() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "choose_serial_port" + assert result["data_schema"].schema[CONF_USB_PATH] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USB_PATH: "/test", + }, + ) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + assert set_addon_options.call_args == call( + "core_zwave_js", AddonsOptions(config={"device": "/test"}) + ) + + await hass.async_block_till_done() + + assert restart_addon.call_args == call("core_zwave_js") + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + assert client.connect.call_count == 2 + + await hass.async_block_till_done() + assert client.connect.call_count == 3 + assert entry.state is config_entries.ConfigEntryState.LOADED + assert client.driver.controller.async_restore_nvm.call_count == 1 + assert len(events) == 2 + assert events[0].data["progress"] == 0.25 + assert events[1].data["progress"] == 0.75 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migration_successful" + assert integration.data["url"] == "ws://host1:3001" + assert integration.data["usb_path"] == "/test" + assert integration.data["use_addon"] is True + + +async def test_reconfigure_migrate_backup_failure( + hass: HomeAssistant, integration, client +) -> None: + """Test backup failure.""" + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="1234", data={**entry.data, "use_addon": True} + ) + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=FailedCommand("test_error", "unknown_error") + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "backup_failed" + + +async def test_reconfigure_migrate_backup_file_failure( + hass: HomeAssistant, integration, client +) -> None: + """Test backup file failure.""" + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="1234", data={**entry.data, "use_addon": True} + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch( + "pathlib.Path.write_bytes", MagicMock(side_effect=OSError("test_error")) + ): + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "backup_failed" + + +@pytest.mark.usefixtures("supervisor", "addon_running", "get_addon_discovery_info") +@pytest.mark.parametrize( + "discovery_info", + [ + [ + Discovery( + addon="core_zwave_js", + service="zwave_js", + uuid=uuid4(), + config=ADDON_DISCOVERY_INFO, + ) + ] + ], +) +async def test_reconfigure_migrate_start_addon_failure( + hass: HomeAssistant, + client: MagicMock, + integration: MockConfigEntry, + restart_addon: AsyncMock, + set_addon_options: AsyncMock, +) -> None: + """Test add-on start failure during migration.""" + restart_addon.side_effect = SupervisorError("Boom!") + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="1234", data={**entry.data, "use_addon": True} + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + client.driver.controller.async_restore_nvm = AsyncMock( + side_effect=FailedCommand("test_error", "unknown_error") + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "choose_serial_port" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USB_PATH: "/test", + }, + ) + + assert set_addon_options.call_count == 1 + assert set_addon_options.call_args == call( + "core_zwave_js", AddonsOptions(config={"device": "/test"}) + ) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "addon_start_failed" + + +@pytest.mark.parametrize( + "discovery_info", + [ + [ + Discovery( + addon="core_zwave_js", + service="zwave_js", + uuid=uuid4(), + config=ADDON_DISCOVERY_INFO, + ) + ] + ], +) +async def test_reconfigure_migrate_restore_failure( + hass: HomeAssistant, + client, + supervisor, + integration, + addon_running, + restart_addon, + set_addon_options, + get_addon_discovery_info, +) -> None: + """Test restore failure.""" + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="1234", data={**entry.data, "use_addon": True} + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + client.driver.controller.async_restore_nvm = AsyncMock( + side_effect=FailedCommand("test_error", "unknown_error") + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "choose_serial_port" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USB_PATH: "/test", + }, + ) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + + await hass.async_block_till_done() + + assert client.driver.controller.async_restore_nvm.call_count == 1 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "restore_failed" + assert result["description_placeholders"]["file_path"] + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + + await hass.async_block_till_done() + + assert client.driver.controller.async_restore_nvm.call_count == 2 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "restore_failed" + + hass.config_entries.flow.async_abort(result["flow_id"]) + + assert len(hass.config_entries.flow.async_progress()) == 0 + + +async def test_get_driver_failure(hass: HomeAssistant, integration, client) -> None: + """Test get driver failure.""" + entry = integration + hass.config_entries.async_update_entry( + integration, unique_id="1234", data={**integration.data, "use_addon": True} + ) + result = await entry.start_reconfigure_flow(hass) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + await hass.config_entries.async_unload(integration.entry_id) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "config_entry_not_loaded" + + +async def test_hard_reset_failure(hass: HomeAssistant, integration, client) -> None: + """Test hard reset failure.""" + entry = integration + hass.config_entries.async_update_entry( + integration, unique_id="1234", data={**integration.data, "use_addon": True} + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + client.driver.async_hard_reset = AsyncMock( + side_effect=FailedCommand("test_error", "unknown_error") + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reset_failed" + + +async def test_choose_serial_port_usb_ports_failure( + hass: HomeAssistant, integration, client +) -> None: + """Test choose serial port usb ports failure.""" + entry = integration + hass.config_entries.async_update_entry( + integration, unique_id="1234", data={**integration.data, "use_addon": True} + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + with patch( + "homeassistant.components.zwave_js.config_flow.async_get_usb_ports", + side_effect=OSError("test_error"), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "usb_ports_failed" + + +async def test_configure_addon_usb_ports_failure( + hass: HomeAssistant, integration, addon_installed, supervisor +) -> None: + """Test configure addon usb ports failure.""" + entry = integration + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "on_supervisor_reconfigure" + + with patch( + "homeassistant.components.zwave_js.config_flow.async_get_usb_ports", + side_effect=OSError("test_error"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "usb_ports_failed" diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 2551fc7b34a..25ab6a87200 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -29,12 +29,19 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.FAN] + + async def test_generic_fan( hass: HomeAssistant, client, fan_generic, integration ) -> None: diff --git a/tests/components/zwave_js/test_helpers.py b/tests/components/zwave_js/test_helpers.py index 2df2e134f49..356707fb5f8 100644 --- a/tests/components/zwave_js/test_helpers.py +++ b/tests/components/zwave_js/test_helpers.py @@ -1,17 +1,27 @@ """Test the Z-Wave JS helpers module.""" -import voluptuous as vol +from unittest.mock import patch +import pytest +import voluptuous as vol +from zwave_js_server.const import SecurityClass +from zwave_js_server.model.controller import ProvisioningEntry + +from homeassistant.components.zwave_js.const import DOMAIN from homeassistant.components.zwave_js.helpers import ( async_get_node_status_sensor_entity_id, async_get_nodes_from_area_id, + async_get_provisioning_entry_from_device_id, get_value_state_schema, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar, device_registry as dr from tests.common import MockConfigEntry +CONTROLLER_PATCH_PREFIX = "zwave_js_server.model.controller.Controller" + async def test_async_get_node_status_sensor_entity_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry @@ -43,3 +53,82 @@ async def test_get_value_state_schema_boolean_config_value( ) assert isinstance(schema_validator, vol.Coerce) assert schema_validator.type is bool + + +async def test_async_get_provisioning_entry_from_device_id( + hass: HomeAssistant, client, device_registry: dr.DeviceRegistry, integration +) -> None: + """Test async_get_provisioning_entry_from_device_id function.""" + device = device_registry.async_get_or_create( + config_entry_id=integration.entry_id, + identifiers={(DOMAIN, "test-device")}, + ) + + provisioning_entry = ProvisioningEntry.from_dict( + { + "dsk": "test", + "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], + "device_id": device.id, + } + ) + + with patch( + f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entries", + return_value=[provisioning_entry], + ): + result = await async_get_provisioning_entry_from_device_id(hass, device.id) + assert result == provisioning_entry + + # Test invalid device + with pytest.raises(ValueError, match="Device ID not-a-real-device is not valid"): + await async_get_provisioning_entry_from_device_id(hass, "not-a-real-device") + + # Test device exists but is not from a zwave_js config entry + non_zwave_config_entry = MockConfigEntry(domain="not_zwave_js") + non_zwave_config_entry.add_to_hass(hass) + non_zwave_device = device_registry.async_get_or_create( + config_entry_id=non_zwave_config_entry.entry_id, + identifiers={("not_zwave_js", "test-device")}, + ) + with pytest.raises( + ValueError, + match=f"Device {non_zwave_device.id} is not from an existing zwave_js config entry", + ): + await async_get_provisioning_entry_from_device_id(hass, non_zwave_device.id) + + # Test device exists but config entry is not loaded + not_loaded_config_entry = MockConfigEntry( + domain=DOMAIN, state=ConfigEntryState.NOT_LOADED + ) + not_loaded_config_entry.add_to_hass(hass) + not_loaded_device = device_registry.async_get_or_create( + config_entry_id=not_loaded_config_entry.entry_id, + identifiers={(DOMAIN, "not-loaded-device")}, + ) + with pytest.raises( + ValueError, match=f"Device {not_loaded_device.id} config entry is not loaded" + ): + await async_get_provisioning_entry_from_device_id(hass, not_loaded_device.id) + + # Test no matching provisioning entry + with patch( + f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entries", + return_value=[], + ): + result = await async_get_provisioning_entry_from_device_id(hass, device.id) + assert result is None + + # Test multiple provisioning entries but only one matches + other_provisioning_entry = ProvisioningEntry.from_dict( + { + "dsk": "other", + "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], + "device_id": "other-id", + } + ) + with patch( + f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entries", + return_value=[other_provisioning_entry, provisioning_entry], + ): + result = await async_get_provisioning_entry_from_device_id(hass, device.id) + assert result == provisioning_entry diff --git a/tests/components/zwave_js/test_humidifier.py b/tests/components/zwave_js/test_humidifier.py index 261e09babee..78ea7899287 100644 --- a/tests/components/zwave_js/test_humidifier.py +++ b/tests/components/zwave_js/test_humidifier.py @@ -1,5 +1,6 @@ """Test the Z-Wave JS humidifier platform.""" +import pytest from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.humidity_control import HumidityControlMode from zwave_js_server.event import Event @@ -22,12 +23,19 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant from .common import DEHUMIDIFIER_ADC_T3000_ENTITY, HUMIDIFIER_ADC_T3000_ENTITY +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.HUMIDIFIER] + + async def test_humidifier( hass: HomeAssistant, client, climate_adc_t3000, integration ) -> None: diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 91e333f7c7d..a0423efdf52 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1,6 +1,7 @@ """Test the Z-Wave JS init module.""" import asyncio +from collections.abc import Generator from copy import deepcopy import logging from typing import Any @@ -10,20 +11,21 @@ from aiohasupervisor import SupervisorError from aiohasupervisor.models import AddonsOptions import pytest from zwave_js_server.client import Client +from zwave_js_server.const import SecurityClass from zwave_js_server.event import Event from zwave_js_server.exceptions import ( BaseZwaveJSServerError, InvalidServerVersion, NotConnected, ) -from zwave_js_server.model.node import Node +from zwave_js_server.model.controller import ProvisioningEntry +from zwave_js_server.model.node import Node, NodeDataType from zwave_js_server.model.version import VersionInfo from homeassistant.components.hassio import HassioAPIError -from homeassistant.components.logger import DOMAIN as LOGGER_DOMAIN, SERVICE_SET_LEVEL from homeassistant.components.persistent_notification import async_dismiss from homeassistant.components.zwave_js import DOMAIN -from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.components.zwave_js.helpers import get_device_id, get_device_id_ext from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import CoreState, HomeAssistant @@ -39,20 +41,27 @@ from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY from tests.common import ( MockConfigEntry, + async_call_logger_set_level, async_fire_time_changed, async_get_persistent_notifications, ) from tests.typing import WebSocketGenerator +CONTROLLER_PATCH_PREFIX = "zwave_js_server.model.controller.Controller" + @pytest.fixture(name="connect_timeout") -def connect_timeout_fixture(): +def connect_timeout_fixture() -> Generator[int]: """Mock the connect timeout.""" with patch("homeassistant.components.zwave_js.CONNECT_TIMEOUT", new=0) as timeout: yield timeout -async def test_entry_setup_unload(hass: HomeAssistant, client, integration) -> None: +async def test_entry_setup_unload( + hass: HomeAssistant, + client: MagicMock, + integration: MockConfigEntry, +) -> None: """Test the integration set up and unload.""" entry = integration @@ -65,16 +74,19 @@ async def test_entry_setup_unload(hass: HomeAssistant, client, integration) -> N assert entry.state is ConfigEntryState.NOT_LOADED -async def test_home_assistant_stop(hass: HomeAssistant, client, integration) -> None: +@pytest.mark.usefixtures("integration") +async def test_home_assistant_stop( + hass: HomeAssistant, + client: MagicMock, +) -> None: """Test we clean up on home assistant stop.""" await hass.async_stop() assert client.disconnect.call_count == 1 -async def test_initialized_timeout( - hass: HomeAssistant, client, connect_timeout -) -> None: +@pytest.mark.usefixtures("client", "connect_timeout") +async def test_initialized_timeout(hass: HomeAssistant) -> None: """Test we handle a timeout during client initialization.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) @@ -85,7 +97,8 @@ async def test_initialized_timeout( assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_enabled_statistics(hass: HomeAssistant, client) -> None: +@pytest.mark.usefixtures("client") +async def test_enabled_statistics(hass: HomeAssistant) -> None: """Test that we enabled statistics if the entry is opted in.""" entry = MockConfigEntry( domain="zwave_js", @@ -101,8 +114,9 @@ async def test_enabled_statistics(hass: HomeAssistant, client) -> None: assert mock_cmd.called -async def test_disabled_statistics(hass: HomeAssistant, client) -> None: - """Test that we diisabled statistics if the entry is opted out.""" +@pytest.mark.usefixtures("client") +async def test_disabled_statistics(hass: HomeAssistant) -> None: + """Test that we disabled statistics if the entry is opted out.""" entry = MockConfigEntry( domain="zwave_js", data={"url": "ws://test.org", "data_collection_opted_in": False}, @@ -117,7 +131,8 @@ async def test_disabled_statistics(hass: HomeAssistant, client) -> None: assert mock_cmd.called -async def test_noop_statistics(hass: HomeAssistant, client) -> None: +@pytest.mark.usefixtures("client") +async def test_noop_statistics(hass: HomeAssistant) -> None: """Test that we don't make statistics calls if user hasn't set preference.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) @@ -266,10 +281,13 @@ async def test_listen_done_during_setup_after_forward_entry( """Test listen task finishing during setup after forward entry.""" assert hass.state is CoreState.running + original_send_command_side_effect = client.async_send_command.side_effect + async def send_command_side_effect(*args: Any, **kwargs: Any) -> None: """Mock send command.""" listen_block.set() getattr(listen_result, listen_future_result_method)(listen_future_result) + client.async_send_command.side_effect = original_send_command_side_effect # Yield to allow the listen task to run await asyncio.sleep(0) @@ -347,8 +365,11 @@ async def test_listen_done_after_setup( assert client.disconnect.call_count == disconnect_call_count +@pytest.mark.usefixtures("client") async def test_new_entity_on_value_added( - hass: HomeAssistant, multisensor_6, client, integration + hass: HomeAssistant, + multisensor_6: Node, + integration: MockConfigEntry, ) -> None: """Test we create a new entity if a value is added after the fact.""" node: Node = multisensor_6 @@ -382,12 +403,12 @@ async def test_new_entity_on_value_added( assert hass.states.get("sensor.multisensor_6_ultraviolet_10") is not None +@pytest.mark.usefixtures("integration") async def test_on_node_added_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - multisensor_6_state, - client, - integration, + multisensor_6_state: NodeDataType, + client: MagicMock, ) -> None: """Test we handle a node added event with a ready node.""" node = Node(client, deepcopy(multisensor_6_state)) @@ -413,13 +434,53 @@ async def test_on_node_added_ready( ) +async def test_on_node_added_preprovisioned( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + multisensor_6_state, + client, + integration, +) -> None: + """Test node added event with a preprovisioned device.""" + dsk = "test" + node = Node(client, deepcopy(multisensor_6_state)) + device = device_registry.async_get_or_create( + config_entry_id=integration.entry_id, + identifiers={(DOMAIN, f"provision_{dsk}")}, + ) + provisioning_entry = ProvisioningEntry.from_dict( + { + "dsk": dsk, + "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], + "device_id": device.id, + } + ) + with patch( + f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entry", + side_effect=lambda id: provisioning_entry if id == node.node_id else None, + ): + event = {"node": node} + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + device = device_registry.async_get(device.id) + assert device + assert device.identifiers == { + get_device_id(client.driver, node), + get_device_id_ext(client.driver, node), + } + assert device.sw_version == node.firmware_version + # There should only be the controller and the preprovisioned device + assert len(device_registry.devices) == 2 + + +@pytest.mark.usefixtures("integration") async def test_on_node_added_not_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - zp3111_not_ready_state, - client, - integration, + zp3111_not_ready_state: NodeDataType, + client: MagicMock, ) -> None: """Test we handle a node added event with a non-ready node.""" device_id = f"{client.driver.controller.home_id}-{zp3111_not_ready_state['nodeId']}" @@ -455,9 +516,9 @@ async def test_on_node_added_not_ready( async def test_existing_node_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - client, - multisensor_6, - integration, + client: MagicMock, + multisensor_6: Node, + integration: MockConfigEntry, ) -> None: """Test we handle a ready node that exists during integration setup.""" node = multisensor_6 @@ -485,7 +546,7 @@ async def test_existing_node_reinterview( hass: HomeAssistant, device_registry: dr.DeviceRegistry, client: Client, - multisensor_6_state: dict, + multisensor_6_state: NodeDataType, multisensor_6: Node, integration: MockConfigEntry, ) -> None: @@ -544,15 +605,16 @@ async def test_existing_node_not_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - zp3111_not_ready, - client, - integration, + client: MagicMock, + zp3111_not_ready: Node, + integration: MockConfigEntry, ) -> None: """Test we handle a non-ready node that exists during integration setup.""" node = zp3111_not_ready device_id = f"{client.driver.controller.home_id}-{node.node_id}" device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device assert device.name == f"Node {node.node_id}" assert not device.manufacturer assert not device.model @@ -573,11 +635,11 @@ async def test_existing_node_not_replaced_when_not_ready( area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - zp3111, - zp3111_not_ready_state, - zp3111_state, - client, - integration, + client: MagicMock, + zp3111: Node, + zp3111_not_ready_state: NodeDataType, + zp3111_state: NodeDataType, + integration: MockConfigEntry, ) -> None: """Test when a node added event with a non-ready node is received. @@ -699,21 +761,23 @@ async def test_existing_node_not_replaced_when_not_ready( assert state.name == "Custom Entity Name" +@pytest.mark.usefixtures("client") async def test_null_name( - hass: HomeAssistant, client, null_name_check, integration + hass: HomeAssistant, + null_name_check: Node, + integration: MockConfigEntry, ) -> None: """Test that node without a name gets a generic node name.""" node = null_name_check assert hass.states.get(f"switch.node_{node.node_id}") +@pytest.mark.usefixtures("addon_installed", "addon_info") async def test_start_addon( hass: HomeAssistant, - addon_installed, - install_addon, - addon_options, - set_addon_options, - start_addon, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test start the Z-Wave JS add-on during entry setup.""" device = "/test" @@ -761,13 +825,12 @@ async def test_start_addon( assert start_addon.call_args == call("core_zwave_js") +@pytest.mark.usefixtures("addon_not_installed", "addon_info") async def test_install_addon( hass: HomeAssistant, - addon_not_installed, - install_addon, - addon_options, - set_addon_options, - start_addon, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test install and start the Z-Wave JS add-on during entry setup.""" device = "/test" @@ -810,14 +873,12 @@ async def test_install_addon( assert start_addon.call_args == call("core_zwave_js") +@pytest.mark.usefixtures("addon_installed", "addon_info", "set_addon_options") @pytest.mark.parametrize("addon_info_side_effect", [SupervisorError("Boom")]) async def test_addon_info_failure( hass: HomeAssistant, - addon_installed, - install_addon, - addon_options, - set_addon_options, - start_addon, + install_addon: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test failure to get add-on info for Z-Wave JS add-on during entry setup.""" device = "/test" @@ -837,6 +898,7 @@ async def test_addon_info_failure( assert start_addon.call_count == 0 +@pytest.mark.usefixtures("addon_running", "addon_info", "client") @pytest.mark.parametrize( ( "old_device", @@ -875,26 +937,23 @@ async def test_addon_info_failure( ) async def test_addon_options_changed( hass: HomeAssistant, - client, - addon_installed, - addon_running, - install_addon, - addon_options, - start_addon, - old_device, - new_device, - old_s0_legacy_key, - new_s0_legacy_key, - old_s2_access_control_key, - new_s2_access_control_key, - old_s2_authenticated_key, - new_s2_authenticated_key, - old_s2_unauthenticated_key, - new_s2_unauthenticated_key, - old_lr_s2_access_control_key, - new_lr_s2_access_control_key, - old_lr_s2_authenticated_key, - new_lr_s2_authenticated_key, + install_addon: AsyncMock, + addon_options: dict[str, Any], + start_addon: AsyncMock, + old_device: str, + new_device: str, + old_s0_legacy_key: str, + new_s0_legacy_key: str, + old_s2_access_control_key: str, + new_s2_access_control_key: str, + old_s2_authenticated_key: str, + new_s2_authenticated_key: str, + old_s2_unauthenticated_key: str, + new_s2_unauthenticated_key: str, + old_lr_s2_access_control_key: str, + new_lr_s2_access_control_key: str, + old_lr_s2_authenticated_key: str, + new_lr_s2_authenticated_key: str, ) -> None: """Test update config entry data on entry setup if add-on options changed.""" addon_options["device"] = new_device @@ -936,6 +995,7 @@ async def test_addon_options_changed( assert start_addon.call_count == 0 +@pytest.mark.usefixtures("addon_running") @pytest.mark.parametrize( ( "addon_version", @@ -954,20 +1014,17 @@ async def test_addon_options_changed( ) async def test_update_addon( hass: HomeAssistant, - client, - addon_info, - addon_installed, - addon_running, - create_backup, - update_addon, - addon_options, - addon_version, - update_available, - update_calls, - backup_calls, - update_addon_side_effect, - create_backup_side_effect, - version_state, + client: MagicMock, + addon_info: AsyncMock, + create_backup: AsyncMock, + update_addon: AsyncMock, + addon_options: dict[str, Any], + addon_version: str, + update_available: bool, + update_calls: int, + backup_calls: int, + update_addon_side_effect: Exception | None, + create_backup_side_effect: Exception | None, ) -> None: """Test update the Z-Wave JS add-on during entry setup.""" device = "/test" @@ -1002,7 +1059,9 @@ async def test_update_addon( async def test_issue_registry( - hass: HomeAssistant, client, version_state, issue_registry: ir.IssueRegistry + hass: HomeAssistant, + client: MagicMock, + issue_registry: ir.IssueRegistry, ) -> None: """Test issue registry.""" device = "/test" @@ -1043,6 +1102,7 @@ async def test_issue_registry( assert not issue_registry.async_get_issue(DOMAIN, "invalid_server_version") +@pytest.mark.usefixtures("addon_running", "client") @pytest.mark.parametrize( ("stop_addon_side_effect", "entry_state"), [ @@ -1052,13 +1112,10 @@ async def test_issue_registry( ) async def test_stop_addon( hass: HomeAssistant, - client, - addon_installed, - addon_running, - addon_options, - stop_addon, - stop_addon_side_effect, - entry_state, + addon_options: dict[str, Any], + stop_addon: AsyncMock, + stop_addon_side_effect: Exception | None, + entry_state: ConfigEntryState, ) -> None: """Test stop the Z-Wave JS add-on on entry unload if entry is disabled.""" stop_addon.side_effect = stop_addon_side_effect @@ -1093,12 +1150,12 @@ async def test_stop_addon( assert stop_addon.call_args == call("core_zwave_js") +@pytest.mark.usefixtures("addon_installed") async def test_remove_entry( hass: HomeAssistant, - addon_installed, - stop_addon, - create_backup, - uninstall_addon, + stop_addon: AsyncMock, + create_backup: AsyncMock, + uninstall_addon: AsyncMock, caplog: pytest.LogCaptureFixture, ) -> None: """Test remove the config entry.""" @@ -1209,13 +1266,12 @@ async def test_remove_entry( assert "Failed to uninstall the Z-Wave JS add-on" in caplog.text +@pytest.mark.usefixtures("climate_radio_thermostat_ct100_plus", "lock_schlage_be469") async def test_removed_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - client, - climate_radio_thermostat_ct100_plus, - lock_schlage_be469, - integration, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test that the device registry gets updated when a device gets removed.""" driver = client.driver @@ -1245,12 +1301,11 @@ async def test_removed_device( ) +@pytest.mark.usefixtures("client", "eaton_rf9640_dimmer") async def test_suggested_area( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - client, - eaton_rf9640_dimmer, ) -> None: """Test that suggested area works.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) @@ -1258,16 +1313,20 @@ async def test_suggested_area( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - entity = entity_registry.async_get(EATON_RF9640_ENTITY) - assert device_registry.async_get(entity.device_id).area_id is not None + entity_entry = entity_registry.async_get(EATON_RF9640_ENTITY) + assert entity_entry + assert entity_entry.device_id is not None + device = device_registry.async_get(entity_entry.device_id) + assert device + assert device.area_id is not None async def test_node_removed( hass: HomeAssistant, device_registry: dr.DeviceRegistry, multisensor_6_state, - client, - integration, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test that device gets removed when node gets removed.""" node = Node(client, deepcopy(multisensor_6_state)) @@ -1296,10 +1355,10 @@ async def test_node_removed( async def test_replace_same_node( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - multisensor_6, - multisensor_6_state, - client, - integration, + multisensor_6: Node, + multisensor_6_state: NodeDataType, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test when a node is replaced with itself that the device remains.""" node_id = multisensor_6.node_id @@ -1406,11 +1465,11 @@ async def test_replace_same_node( async def test_replace_different_node( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - multisensor_6, - multisensor_6_state, - hank_binary_switch_state, - client, - integration, + multisensor_6: Node, + multisensor_6_state: NodeDataType, + hank_binary_switch_state: NodeDataType, + client: MagicMock, + integration: MockConfigEntry, hass_ws_client: WebSocketGenerator, ) -> None: """Test when a node is replaced with a different node.""" @@ -1659,9 +1718,9 @@ async def test_node_model_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - zp3111, - client, - integration, + zp3111: Node, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test when a node's model is changed due to an updated device config file. @@ -1745,8 +1804,11 @@ async def test_node_model_change( assert state.name == "Custom Entity Name" +@pytest.mark.usefixtures("zp3111", "integration") async def test_disabled_node_status_entity_on_node_replaced( - hass: HomeAssistant, zp3111_state, zp3111, client, integration + hass: HomeAssistant, + zp3111_state: NodeDataType, + client: MagicMock, ) -> None: """Test when node replacement event is received, node status sensor is removed.""" node_status_entity = "sensor.4_in_1_sensor_node_status" @@ -1772,7 +1834,10 @@ async def test_disabled_node_status_entity_on_node_replaced( async def test_disabled_entity_on_value_removed( - hass: HomeAssistant, entity_registry: er.EntityRegistry, zp3111, client, integration + hass: HomeAssistant, + zp3111: Node, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test that when entity primary values are removed the entity is removed.""" idle_cover_status_button_entity = ( @@ -1903,7 +1968,10 @@ async def test_disabled_entity_on_value_removed( async def test_identify_event( - hass: HomeAssistant, client, multisensor_6, integration + hass: HomeAssistant, + client: MagicMock, + multisensor_6: Node, + integration: MockConfigEntry, ) -> None: """Test controller identify event.""" # One config entry scenario @@ -1950,7 +2018,9 @@ async def test_identify_event( assert "network with the home ID `3245146787`" in notifications[msg_id]["message"] -async def test_server_logging(hass: HomeAssistant, client) -> None: +async def test_server_logging( + hass: HomeAssistant, client: MagicMock, caplog: pytest.LogCaptureFixture +) -> None: """Test automatic server logging functionality.""" def _reset_mocks(): @@ -1969,85 +2039,91 @@ async def test_server_logging(hass: HomeAssistant, client) -> None: # Setup logger and set log level to debug to trigger event listener assert await async_setup_component(hass, "logger", {"logger": {}}) - assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.INFO - client.async_send_command.reset_mock() - await hass.services.async_call( - LOGGER_DOMAIN, SERVICE_SET_LEVEL, {"zwave_js_server": "debug"}, blocking=True - ) - await hass.async_block_till_done() assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.DEBUG + client.async_send_command.reset_mock() + async with async_call_logger_set_level( + "zwave_js_server", "DEBUG", hass=hass, caplog=caplog + ): + assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.DEBUG - # Validate that the server logging was enabled - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "driver.update_log_config", - "config": {"level": "debug"}, - } - assert client.enable_server_logging.called - assert not client.disable_server_logging.called + # Validate that the server logging was enabled + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "driver.update_log_config", + "config": {"level": "debug"}, + } + assert client.enable_server_logging.called + assert not client.disable_server_logging.called - _reset_mocks() + _reset_mocks() - # Emulate server by setting log level to debug - event = Event( - type="log config updated", - data={ - "source": "driver", - "event": "log config updated", - "config": { - "enabled": False, - "level": "debug", - "logToFile": True, - "filename": "test", - "forceConsole": True, + # Emulate server by setting log level to debug + event = Event( + type="log config updated", + data={ + "source": "driver", + "event": "log config updated", + "config": { + "enabled": False, + "level": "debug", + "logToFile": True, + "filename": "test", + "forceConsole": True, + }, }, - }, - ) - client.driver.receive_event(event) + ) + client.driver.receive_event(event) - # "Enable" server logging and unload the entry - client.server_logging_enabled = True - await hass.config_entries.async_unload(entry.entry_id) + # "Enable" server logging and unload the entry + client.server_logging_enabled = True + await hass.config_entries.async_unload(entry.entry_id) - # Validate that the server logging was disabled - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "driver.update_log_config", - "config": {"level": "info"}, - } - assert not client.enable_server_logging.called - assert client.disable_server_logging.called + # Validate that the server logging was disabled + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "driver.update_log_config", + "config": {"level": "info"}, + } + assert not client.enable_server_logging.called + assert client.disable_server_logging.called - _reset_mocks() + _reset_mocks() - # Validate that the server logging doesn't get enabled because HA thinks it already - # is enabled - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 0 - assert not client.enable_server_logging.called - assert not client.disable_server_logging.called + # Validate that the server logging doesn't get enabled because HA thinks it already + # is enabled + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert len(client.async_send_command.call_args_list) == 2 + assert client.async_send_command.call_args_list[0][0][0] == { + "command": "controller.get_provisioning_entries", + } + assert client.async_send_command.call_args_list[1][0][0] == { + "command": "controller.get_provisioning_entry", + "dskOrNodeId": 1, + } + assert not client.enable_server_logging.called + assert not client.disable_server_logging.called - _reset_mocks() + _reset_mocks() - # "Disable" server logging and unload the entry - client.server_logging_enabled = False - await hass.config_entries.async_unload(entry.entry_id) + # "Disable" server logging and unload the entry + client.server_logging_enabled = False + await hass.config_entries.async_unload(entry.entry_id) - # Validate that the server logging was not disabled because HA thinks it is already - # is disabled - assert len(client.async_send_command.call_args_list) == 0 - assert not client.enable_server_logging.called - assert not client.disable_server_logging.called + # Validate that the server logging was not disabled because HA thinks it is already + # is disabled + assert len(client.async_send_command.call_args_list) == 0 + assert not client.enable_server_logging.called + assert not client.disable_server_logging.called async def test_factory_reset_node( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - client, - multisensor_6, - multisensor_6_state, - integration, + client: MagicMock, + multisensor_6: Node, + multisensor_6_state: NodeDataType, + integration: MockConfigEntry, ) -> None: """Test when a node is removed because it was reset.""" # One config entry scenario diff --git a/tests/components/zwave_js/test_number.py b/tests/components/zwave_js/test_number.py index f5d7bf28169..e2c182d81d9 100644 --- a/tests/components/zwave_js/test_number.py +++ b/tests/components/zwave_js/test_number.py @@ -123,7 +123,7 @@ async def test_number_writeable( blocking=True, ) - assert len(client.async_send_command.call_args_list) == 2 + assert len(client.async_send_command.call_args_list) == 5 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 4 diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 6a4f48a0dc5..fc225d529a6 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -324,12 +324,12 @@ async def test_update_entity_ha_not_running( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 1 + assert len(client.async_send_command.call_args_list) == 4 await hass.async_start() await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 1 + assert len(client.async_send_command.call_args_list) == 4 # Update should be delayed by a day because HA is not running hass.set_state(CoreState.starting) @@ -337,15 +337,15 @@ async def test_update_entity_ha_not_running( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 1 + assert len(client.async_send_command.call_args_list) == 4 hass.set_state(CoreState.running) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 2 - args = client.async_send_command.call_args_list[1][0][0] + assert len(client.async_send_command.call_args_list) == 5 + args = client.async_send_command.call_args_list[4][0][0] assert args["command"] == "controller.get_available_firmware_updates" assert args["nodeId"] == zen_31.node_id @@ -651,12 +651,12 @@ async def test_update_entity_delay( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 2 + assert len(client.async_send_command.call_args_list) == 6 await hass.async_start() await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 2 + assert len(client.async_send_command.call_args_list) == 6 update_interval = timedelta(minutes=5) freezer.tick(update_interval) @@ -665,8 +665,8 @@ async def test_update_entity_delay( nodes: set[int] = set() - assert len(client.async_send_command.call_args_list) == 3 - args = client.async_send_command.call_args_list[2][0][0] + assert len(client.async_send_command.call_args_list) == 7 + args = client.async_send_command.call_args_list[6][0][0] assert args["command"] == "controller.get_available_firmware_updates" nodes.add(args["nodeId"]) @@ -674,8 +674,8 @@ async def test_update_entity_delay( async_fire_time_changed(hass) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 4 - args = client.async_send_command.call_args_list[3][0][0] + assert len(client.async_send_command.call_args_list) == 8 + args = client.async_send_command.call_args_list[7][0][0] assert args["command"] == "controller.get_available_firmware_updates" nodes.add(args["nodeId"]) @@ -846,8 +846,8 @@ async def test_update_entity_full_restore_data_update_available( assert attrs[ATTR_IN_PROGRESS] is True assert attrs[ATTR_UPDATE_PERCENTAGE] is None - assert len(client.async_send_command.call_args_list) == 2 - assert client.async_send_command.call_args_list[1][0][0] == { + assert len(client.async_send_command.call_args_list) == 5 + assert client.async_send_command.call_args_list[4][0][0] == { "command": "controller.firmware_update_ota", "nodeId": climate_radio_thermostat_ct100_plus_different_endpoints.node_id, "updateInfo": { diff --git a/tests/conftest.py b/tests/conftest.py index 65e3518956e..9b861d5bde5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -119,8 +119,10 @@ from .typing import ( if TYPE_CHECKING: # Local import to avoid processing recorder and SQLite modules when running a # testcase which does not use the recorder. + from homeassistant.auth.models import RefreshToken from homeassistant.components import recorder + pytest.register_assert_rewrite("tests.common") from .common import ( # noqa: E402, isort:skip @@ -284,6 +286,7 @@ def garbage_collection() -> None: to run per test case if needed. """ gc.collect() + gc.freeze() @pytest.fixture(autouse=True) @@ -852,7 +855,7 @@ def hass_client_no_auth( @pytest.fixture def current_request() -> Generator[MagicMock]: """Mock current request.""" - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mocked_request = make_mocked_request( "GET", "/some/request", @@ -1316,9 +1319,11 @@ def disable_translations_once( @pytest_asyncio.fixture(autouse=True, scope="session", loop_scope="session") async def mock_zeroconf_resolver() -> AsyncGenerator[_patch]: """Mock out the zeroconf resolver.""" + resolver = AsyncResolver() + resolver.real_close = resolver.close patcher = patch( "homeassistant.helpers.aiohttp_client._async_make_resolver", - return_value=AsyncResolver(), + return_value=resolver, ) patcher.start() try: @@ -1343,9 +1348,13 @@ def mock_zeroconf() -> Generator[MagicMock]: from zeroconf import DNSCache # pylint: disable=import-outside-toplevel with ( - patch("homeassistant.components.zeroconf.HaZeroconf", autospec=True) as mock_zc, - patch("homeassistant.components.zeroconf.AsyncServiceBrowser", autospec=True), + patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, + patch( + "homeassistant.components.zeroconf.discovery.AsyncServiceBrowser", + ) as mock_browser, ): + asb = mock_browser.return_value + asb.async_cancel = AsyncMock() zc = mock_zc.return_value # DNSCache has strong Cython type checks, and MagicMock does not work # so we must mock the class directly @@ -1894,6 +1903,67 @@ def mock_bleak_scanner_start() -> Generator[MagicMock]: yield mock_bleak_scanner_start +@pytest.fixture +def hassio_env(supervisor_is_connected: AsyncMock) -> Generator[None]: + """Fixture to inject hassio env.""" + from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel + HassioAPIError, + ) + + from .components.hassio import ( # pylint: disable=import-outside-toplevel + SUPERVISOR_TOKEN, + ) + + with ( + patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}), + patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}), + patch( + "homeassistant.components.hassio.HassIO.get_info", + Mock(side_effect=HassioAPIError()), + ), + ): + yield + + +@pytest.fixture +async def hassio_stubs( + hassio_env: None, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, +) -> RefreshToken: + """Create mock hassio http client.""" + from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel + HassioAPIError, + ) + + with ( + patch( + "homeassistant.components.hassio.HassIO.update_hass_api", + return_value={"result": "ok"}, + ) as hass_api, + patch( + "homeassistant.components.hassio.HassIO.update_hass_config", + return_value={"result": "ok"}, + ), + patch( + "homeassistant.components.hassio.HassIO.get_info", + side_effect=HassioAPIError(), + ), + patch( + "homeassistant.components.hassio.HassIO.get_ingress_panels", + return_value={"panels": []}, + ), + patch( + "homeassistant.components.hassio.issues.SupervisorIssues.setup", + ), + ): + await async_setup_component(hass, "hassio", {}) + + return hass_api.call_args[0][1] + + @pytest.fixture def integration_frame_path() -> str: """Return the path to the integration frame. diff --git a/tests/hassfest/test_dependencies.py b/tests/hassfest/test_dependencies.py index 84e02b2d9d5..26ed8a01ba8 100644 --- a/tests/hassfest/test_dependencies.py +++ b/tests/hassfest/test_dependencies.py @@ -68,33 +68,6 @@ import homeassistant.components.renamed_absolute as hue assert mock_collector.unfiltered_referenced == {"renamed_absolute"} -def test_hass_components_var(mock_collector) -> None: - """Test detecting a hass_components_var reference.""" - mock_collector.visit( - ast.parse( - """ -def bla(hass): - hass.components.hass_components_var.async_do_something() -""" - ) - ) - assert mock_collector.unfiltered_referenced == {"hass_components_var"} - - -def test_hass_components_class(mock_collector) -> None: - """Test detecting a hass_components_class reference.""" - mock_collector.visit( - ast.parse( - """ -class Hello: - def something(self): - self.hass.components.hass_components_class.async_yo() -""" - ) - ) - assert mock_collector.unfiltered_referenced == {"hass_components_class"} - - def test_all_imports(mock_collector) -> None: """Test all imports together.""" mock_collector.visit( @@ -108,13 +81,6 @@ from homeassistant.components.subimport.smart_home import EVENT_ALEXA_SMART_HOME from homeassistant.components.child_import_field import bla import homeassistant.components.renamed_absolute as hue - -def bla(hass): - hass.components.hass_components_var.async_do_something() - -class Hello: - def something(self): - self.hass.components.hass_components_class.async_yo() """ ) ) @@ -123,6 +89,4 @@ class Hello: "subimport", "child_import_field", "renamed_absolute", - "hass_components_var", - "hass_components_class", } diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 6d2a7e7a8bb..e44111634d1 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -401,3 +401,15 @@ async def test_async_mdnsresolver( resp = await session.post("http://localhost/xyz", json={"x": 1}) assert resp.status == 200 assert await resp.json() == {"x": 1} + + +async def test_resolver_is_singleton(hass: HomeAssistant) -> None: + """Test that the resolver is a singleton.""" + session = client.async_get_clientsession(hass) + session2 = client.async_get_clientsession(hass) + session3 = client.async_create_clientsession(hass) + assert isinstance(session._connector, aiohttp.TCPConnector) + assert isinstance(session2._connector, aiohttp.TCPConnector) + assert isinstance(session3._connector, aiohttp.TCPConnector) + assert session._connector._resolver is session2._connector._resolver + assert session._connector._resolver is session3._connector._resolver diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index c69f039027e..3496c41ecf4 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -494,6 +494,29 @@ async def test_async_get_area_by_name(area_registry: ar.AreaRegistry) -> None: assert area_registry.async_get_area_by_name("M o c k 1").normalized_name == "mock1" +async def test_async_get_areas_by_alias( + area_registry: ar.AreaRegistry, +) -> None: + """Make sure we can get the areas by alias.""" + area1 = area_registry.async_create("Mock1", aliases=("alias_1", "alias_2")) + area2 = area_registry.async_create("Mock2", aliases=("alias_1", "alias_3")) + + assert len(area_registry.areas) == 2 + + alias1_list = area_registry.async_get_areas_by_alias("A l i a s_1") + alias2_list = area_registry.async_get_areas_by_alias("A l i a s_2") + alias3_list = area_registry.async_get_areas_by_alias("A l i a s_3") + + assert len(alias1_list) == 2 + assert len(alias2_list) == 1 + assert len(alias3_list) == 1 + + assert area1 in alias1_list + assert area1 in alias2_list + assert area2 in alias1_list + assert area2 in alias3_list + + async def test_async_get_area_by_name_not_found(area_registry: ar.AreaRegistry) -> None: """Make sure we return None for non-existent areas.""" area_registry.async_create("Mock1") diff --git a/tests/helpers/test_backup.py b/tests/helpers/test_backup.py index 10ff5cb855f..f6a4f28622e 100644 --- a/tests/helpers/test_backup.py +++ b/tests/helpers/test_backup.py @@ -17,6 +17,7 @@ async def test_async_get_manager(hass: HomeAssistant) -> None: backup_helper.async_initialize_backup(hass) task = asyncio.create_task(backup_helper.async_get_manager(hass)) assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + await hass.async_block_till_done() manager = await task assert manager is hass.data[backup_helper.DATA_MANAGER] @@ -36,7 +37,5 @@ async def test_async_get_manager_backup_failed_setup(hass: HomeAssistant) -> Non side_effect=Exception("Boom!"), ): assert not await async_setup_component(hass, BACKUP_DOMAIN, {}) - with ( - pytest.raises(Exception, match="Boom!"), - ): + with pytest.raises(Exception, match="Boom!"): await backup_helper.async_get_manager(hass) diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index c72295493e8..ecf5271dafd 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1953,3 +1953,30 @@ async def test_is_entity_service_schema( vol.All(vol.Schema(cv.make_entity_service_schema({"some": str}))), ): assert cv.is_entity_service_schema(schema) is True + + +def test_renamed(caplog: pytest.LogCaptureFixture, schema) -> None: + """Test renamed.""" + renamed_schema = vol.All(cv.renamed("mors", "mars"), schema) + + test_data = {"mars": True} + output = renamed_schema(test_data.copy()) + assert len(caplog.records) == 0 + assert output == test_data + + test_data = {"mors": True} + output = renamed_schema(test_data.copy()) + assert len(caplog.records) == 0 + assert output == {"mars": True} + + test_data = {"mars": True, "mors": True} + with pytest.raises( + vol.Invalid, + match="Cannot specify both 'mors' and 'mars'. Please use 'mars' only.", + ): + renamed_schema(test_data.copy()) + assert len(caplog.records) == 0 + + # Check error handling if data is not a dict + with pytest.raises(vol.Invalid, match="expected a dictionary"): + renamed_schema([]) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 6cf0e7c54d2..61396d97359 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -13,6 +13,7 @@ from unittest.mock import MagicMock, PropertyMock, patch from freezegun.api import FrozenDateTimeFactory from propcache.api import cached_property import pytest +from pytest_unordered import unordered from syrupy.assertion import SnapshotAssertion import voluptuous as vol @@ -44,6 +45,7 @@ from tests.common import ( MockEntityPlatform, MockModule, MockPlatform, + RegistryEntryWithDefaults, mock_integration, mock_registry, ) @@ -392,7 +394,7 @@ async def test_async_parallel_updates_with_zero_on_sync_update( await asyncio.sleep(0) assert len(updates) == 2 - assert updates == [1, 2] + assert updates == unordered([1, 2]) finally: test_lock.set() await asyncio.sleep(0) @@ -683,7 +685,7 @@ async def test_warn_disabled( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we warn once if we write to a disabled entity.""" - entry = er.RegistryEntry( + entry = RegistryEntryWithDefaults( entity_id="hello.world", unique_id="test-unique-id", platform="test-platform", @@ -710,7 +712,7 @@ async def test_warn_disabled( async def test_disabled_in_entity_registry(hass: HomeAssistant) -> None: """Test entity is removed if we disable entity registry entry.""" - entry = er.RegistryEntry( + entry = RegistryEntryWithDefaults( entity_id="hello.world", unique_id="test-unique-id", platform="test-platform", @@ -1705,13 +1707,15 @@ async def test_invalid_state( assert hass.states.get("test.test").state == "x" * 255 caplog.clear() - ent._attr_state = "x" * 256 + long_state = "x" * 256 + ent._attr_state = long_state ent.async_write_ha_state() assert hass.states.get("test.test").state == STATE_UNKNOWN assert ( - "homeassistant.helpers.entity", + "homeassistant.core", logging.ERROR, - f"Failed to set state for test.test, fall back to {STATE_UNKNOWN}", + f"State {long_state} for test.test is longer than 255, " + f"falling back to {STATE_UNKNOWN}", ) in caplog.record_tuples ent._attr_state = "x" * 255 diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 41b7271150a..8a1bdcb2f0c 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -48,6 +48,7 @@ from tests.common import ( MockEntity, MockEntityPlatform, MockPlatform, + RegistryEntryWithDefaults, async_fire_time_changed, mock_platform, mock_registry, @@ -752,7 +753,7 @@ async def test_overriding_name_from_registry(hass: HomeAssistant) -> None: mock_registry( hass, { - "test_domain.world": er.RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" @@ -785,7 +786,7 @@ async def test_registry_respect_entity_disabled(hass: HomeAssistant) -> None: mock_registry( hass, { - "test_domain.world": er.RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" @@ -832,7 +833,7 @@ async def test_entity_registry_updates_name(hass: HomeAssistant) -> None: registry = mock_registry( hass, { - "test_domain.world": er.RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" @@ -1065,7 +1066,7 @@ async def test_entity_registry_updates_entity_id(hass: HomeAssistant) -> None: registry = mock_registry( hass, { - "test_domain.world": er.RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" @@ -1097,14 +1098,14 @@ async def test_entity_registry_updates_invalid_entity_id(hass: HomeAssistant) -> registry = mock_registry( hass, { - "test_domain.world": er.RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" platform="test_platform", name="Some name", ), - "test_domain.existing": er.RegistryEntry( + "test_domain.existing": RegistryEntryWithDefaults( entity_id="test_domain.existing", unique_id="5678", platform="test_platform", @@ -1529,14 +1530,19 @@ async def test_entity_info_added_to_entity_registry( entry_default = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "default") assert entry_default == er.RegistryEntry( - "test_domain.best_name", - "default", - "test_domain", + entity_id="test_domain.best_name", + unique_id="default", + platform="test_domain", capabilities={"max": 100}, + config_entry_id=None, + config_subentry_id=None, created_at=dt_util.utcnow(), device_class=None, + device_id=None, + disabled_by=None, entity_category=EntityCategory.CONFIG, has_entity_name=True, + hidden_by=None, icon=None, id=ANY, modified_at=dt_util.utcnow(), @@ -1544,6 +1550,7 @@ async def test_entity_info_added_to_entity_registry( original_device_class="mock-device-class", original_icon="nice:icon", original_name="best name", + options=None, supported_features=5, translation_key="my_translation_key", unit_of_measurement=PERCENTAGE, diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 416f2d5121d..7df7bb398e8 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -24,6 +24,7 @@ from homeassistant.util.dt import utc_from_timestamp from tests.common import ( ANY, MockConfigEntry, + RegistryEntryWithDefaults, async_capture_events, async_fire_time_changed, flush_store, @@ -122,9 +123,9 @@ def test_get_or_create_updates_data( assert set(entity_registry.async_device_ids()) == {orig_device_entry.id} assert orig_entry == er.RegistryEntry( - "light.hue_5678", - "5678", - "hue", + entity_id="light.hue_5678", + unique_id="5678", + platform="hue", capabilities={"max": 100}, config_entry_id=orig_config_entry.entry_id, config_subentry_id=config_subentry_id, @@ -139,6 +140,7 @@ def test_get_or_create_updates_data( id=orig_entry.id, modified_at=created, name=None, + options=None, original_device_class="mock-device-class", original_icon="initial-original_icon", original_name="initial-original_name", @@ -177,9 +179,9 @@ def test_get_or_create_updates_data( ) assert new_entry == er.RegistryEntry( - "light.hue_5678", - "5678", - "hue", + entity_id="light.hue_5678", + unique_id="5678", + platform="hue", aliases=set(), area_id=None, capabilities={"new-max": 150}, @@ -196,6 +198,7 @@ def test_get_or_create_updates_data( id=orig_entry.id, modified_at=modified, name=None, + options=None, original_device_class="new-mock-device-class", original_icon="updated-original_icon", original_name="updated-original_name", @@ -228,13 +231,14 @@ def test_get_or_create_updates_data( ) assert new_entry == er.RegistryEntry( - "light.hue_5678", - "5678", - "hue", + entity_id="light.hue_5678", + unique_id="5678", + platform="hue", aliases=set(), area_id=None, capabilities=None, config_entry_id=None, + config_subentry_id=None, created_at=created, device_class=None, device_id=None, @@ -246,6 +250,7 @@ def test_get_or_create_updates_data( id=orig_entry.id, modified_at=modified, name=None, + options=None, original_device_class=None, original_icon=None, original_name=None, @@ -2012,7 +2017,9 @@ async def test_disabled_entities_excluded_from_entity_list( ) == [entry1, entry2] -async def test_entity_max_length_exceeded(entity_registry: er.EntityRegistry) -> None: +async def test_entity_max_length_exceeded( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that an exception is raised when the max character length is exceeded.""" long_domain_name = ( @@ -2037,20 +2044,13 @@ async def test_entity_max_length_exceeded(entity_registry: er.EntityRegistry) -> "1234567890123456789012345678901234567" ) - known = [] - new_id = entity_registry.async_generate_entity_id( - "sensor", long_entity_id_name, known - ) + new_id = entity_registry.async_generate_entity_id("sensor", long_entity_id_name) assert new_id == "sensor." + long_entity_id_name[: 255 - 7] - known.append(new_id) - new_id = entity_registry.async_generate_entity_id( - "sensor", long_entity_id_name, known - ) + hass.states.async_reserve(new_id) + new_id = entity_registry.async_generate_entity_id("sensor", long_entity_id_name) assert new_id == "sensor." + long_entity_id_name[: 255 - 7 - 2] + "_2" - known.append(new_id) - new_id = entity_registry.async_generate_entity_id( - "sensor", long_entity_id_name, known - ) + hass.states.async_reserve(new_id) + new_id = entity_registry.async_generate_entity_id("sensor", long_entity_id_name) assert new_id == "sensor." + long_entity_id_name[: 255 - 7 - 2] + "_3" @@ -2095,8 +2095,12 @@ def test_entity_registry_items() -> None: assert entities.get_entity_id(("a", "b", "c")) is None assert entities.get_entry("abc") is None - entry1 = er.RegistryEntry("test.entity1", "1234", "hue") - entry2 = er.RegistryEntry("test.entity2", "2345", "hue") + entry1 = RegistryEntryWithDefaults( + entity_id="test.entity1", unique_id="1234", platform="hue" + ) + entry2 = RegistryEntryWithDefaults( + entity_id="test.entity2", unique_id="2345", platform="hue" + ) entities["test.entity1"] = entry1 entities["test.entity2"] = entry2 diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index a8691771580..b8bc89e29d7 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -30,6 +30,7 @@ from homeassistant.helpers.event import ( TrackTemplate, TrackTemplateResult, async_call_later, + async_has_entity_registry_updated_listeners, async_track_device_registry_updated_event, async_track_entity_registry_updated_event, async_track_point_in_time, @@ -4682,12 +4683,17 @@ async def test_async_track_entity_registry_updated_event(hass: HomeAssistant) -> def run_callback(event): event_data.append(event.data) + assert async_has_entity_registry_updated_listeners(hass) is False + unsub1 = async_track_entity_registry_updated_event( hass, entity_id, run_callback, job_type=ha.HassJobType.Callback ) unsub2 = async_track_entity_registry_updated_event( hass, new_entity_id, run_callback ) + + assert async_has_entity_registry_updated_listeners(hass) is True + hass.bus.async_fire( EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "entity_id": entity_id} ) diff --git a/tests/helpers/test_floor_registry.py b/tests/helpers/test_floor_registry.py index 6a672399522..5ebd63ae302 100644 --- a/tests/helpers/test_floor_registry.py +++ b/tests/helpers/test_floor_registry.py @@ -327,7 +327,7 @@ async def test_loading_floors_from_storage( assert len(registry.floors) == 1 -async def test_getting_floor(floor_registry: fr.FloorRegistry) -> None: +async def test_getting_floor_by_name(floor_registry: fr.FloorRegistry) -> None: """Make sure we can get the floors by name.""" floor = floor_registry.async_create("First floor") floor2 = floor_registry.async_get_floor_by_name("first floor") @@ -341,6 +341,27 @@ async def test_getting_floor(floor_registry: fr.FloorRegistry) -> None: assert get_floor == floor +async def test_async_get_floors_by_alias( + floor_registry: fr.FloorRegistry, +) -> None: + """Make sure we can get the floors by alias.""" + floor1 = floor_registry.async_create("First floor", aliases=("alias_1", "alias_2")) + floor2 = floor_registry.async_create("Second floor", aliases=("alias_1", "alias_3")) + + alias1_list = floor_registry.async_get_floors_by_alias("A l i a s_1") + alias2_list = floor_registry.async_get_floors_by_alias("A l i a s_2") + alias3_list = floor_registry.async_get_floors_by_alias("A l i a s_3") + + assert len(alias1_list) == 2 + assert len(alias2_list) == 1 + assert len(alias3_list) == 1 + + assert floor1 in alias1_list + assert floor1 in alias2_list + assert floor2 in alias1_list + assert floor2 in alias3_list + + async def test_async_get_floor_by_name_not_found( floor_registry: fr.FloorRegistry, ) -> None: diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index bf0df305c35..aebd989c237 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -6,14 +6,14 @@ from unittest.mock import MagicMock, patch import pytest import voluptuous as vol -from homeassistant.components import conversation, light, switch +from homeassistant.components import light, switch from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, ) -from homeassistant.core import Context, HomeAssistant, State +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import ( area_registry as ar, config_validation as cv, @@ -615,25 +615,6 @@ def test_async_validate_slots_no_schema() -> None: } -async def test_cant_turn_on_lock(hass: HomeAssistant) -> None: - """Test that we can't turn on entities that don't support it.""" - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "conversation", {}) - assert await async_setup_component(hass, "intent", {}) - assert await async_setup_component(hass, "lock", {}) - - hass.states.async_set( - "lock.test", "123", attributes={ATTR_FRIENDLY_NAME: "Test Lock"} - ) - - result = await conversation.async_converse( - hass, "turn on test lock", None, Context(), None - ) - - assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS - - def test_async_register(hass: HomeAssistant) -> None: """Test registering an intent and verifying it is stored correctly.""" handler = MagicMock() diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 42dd4da6197..1a9225c505b 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -7,7 +7,7 @@ from unittest.mock import patch import pytest import voluptuous as vol -from homeassistant.components import calendar +from homeassistant.components import calendar, todo from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.intent import async_register_timer_handler from homeassistant.components.script.config import ScriptConfig @@ -25,6 +25,7 @@ from homeassistant.helpers import ( ) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonObjectType from tests.common import MockConfigEntry, async_mock_service @@ -45,9 +46,12 @@ def llm_context() -> llm.LLMContext: class MyAPI(llm.API): """Test API.""" + prompt: str = "" + tools: list[llm.Tool] = [] + async def async_get_api_instance(self, _: llm.ToolInput) -> llm.APIInstance: """Return a list of tools.""" - return llm.APIInstance(self, "", [], llm_context) + return llm.APIInstance(self, self.prompt, llm_context, self.tools) async def test_get_api_no_existing( @@ -576,6 +580,10 @@ async def test_assist_api_prompt( ) ) exposed_entities_prompt = """Live Context: An overview of the areas and the devices in this smart home: +- names: '1' + domain: light + state: unavailable + areas: Test Area 2 - names: Kitchen domain: light state: 'on' @@ -590,18 +598,6 @@ async def test_assist_api_prompt( domain: light state: unavailable areas: Test Area, Alternative name -- names: Test Service - domain: light - state: unavailable - areas: Test Area, Alternative name -- names: Test Service - domain: light - state: unavailable - areas: Test Area, Alternative name -- names: Test Service - domain: light - state: unavailable - areas: Test Area, Alternative name - names: Test Device 2 domain: light state: unavailable @@ -614,16 +610,27 @@ async def test_assist_api_prompt( domain: light state: unavailable areas: Test Area 2 -- names: Unnamed Device +- names: Test Service domain: light state: unavailable - areas: Test Area 2 -- names: '1' + areas: Test Area, Alternative name +- names: Test Service + domain: light + state: unavailable + areas: Test Area, Alternative name +- names: Test Service + domain: light + state: unavailable + areas: Test Area, Alternative name +- names: Unnamed Device domain: light state: unavailable areas: Test Area 2 """ stateless_exposed_entities_prompt = """Static Context: An overview of the areas and the devices in this smart home: +- names: '1' + domain: light + areas: Test Area 2 - names: Kitchen domain: light - names: Living Room @@ -632,15 +639,6 @@ async def test_assist_api_prompt( - names: Test Device, my test light domain: light areas: Test Area, Alternative name -- names: Test Service - domain: light - areas: Test Area, Alternative name -- names: Test Service - domain: light - areas: Test Area, Alternative name -- names: Test Service - domain: light - areas: Test Area, Alternative name - names: Test Device 2 domain: light areas: Test Area 2 @@ -650,10 +648,16 @@ async def test_assist_api_prompt( - names: Test Device 4 domain: light areas: Test Area 2 -- names: Unnamed Device +- names: Test Service domain: light - areas: Test Area 2 -- names: '1' + areas: Test Area, Alternative name +- names: Test Service + domain: light + areas: Test Area, Alternative name +- names: Test Service + domain: light + areas: Test Area, Alternative name +- names: Unnamed Device domain: light areas: Test Area 2 """ @@ -1328,6 +1332,118 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: } +async def test_todo_get_items_tool(hass: HomeAssistant) -> None: + """Test the todo get items tool.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "todo", {}) + hass.states.async_set( + "todo.test_list", "0", {"friendly_name": "Mock Todo List Name"} + ) + async_expose_entity(hass, "conversation", "todo.test_list", True) + context = Context() + llm_context = llm.LLMContext( + platform="test_platform", + context=context, + user_prompt="test_text", + language="*", + assistant="conversation", + device_id=None, + ) + api = await llm.async_get_api(hass, "assist", llm_context) + tool = next((tool for tool in api.tools if tool.name == "todo_get_items"), None) + assert tool is not None + assert tool.parameters.schema["todo_list"].container == ["Mock Todo List Name"] + + calls = async_mock_service( + hass, + domain=todo.DOMAIN, + service=todo.TodoServices.GET_ITEMS, + schema=cv.make_entity_service_schema(todo.TODO_SERVICE_GET_ITEMS_SCHEMA), + response={ + "todo.test_list": { + "items": [ + { + "uid": "1234", + "summary": "Buy milk", + "status": "needs_action", + }, + { + "uid": "5678", + "summary": "Call mom", + "status": "needs_action", + "due": "2025-09-17", + "description": "Remember birthday", + }, + ] + } + }, + ) + + # Test without status filter (defaults to needs_action) + result = await tool.async_call( + hass, + llm.ToolInput("todo_get_items", {"todo_list": "Mock Todo List Name"}), + llm_context, + ) + + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": ["todo.test_list"], + "status": ["needs_action"], + } + assert result == { + "success": True, + "result": [ + { + "uid": "1234", + "status": "needs_action", + "summary": "Buy milk", + }, + { + "uid": "5678", + "status": "needs_action", + "summary": "Call mom", + "due": "2025-09-17", + "description": "Remember birthday", + }, + ], + } + + # Test that the status filter is passed correctly to the service call. + # We don't assert on the response since it is fixed above. + calls.clear() + result = await tool.async_call( + hass, + llm.ToolInput( + "todo_get_items", + {"todo_list": "Mock Todo List Name", "status": "completed"}, + ), + llm_context, + ) + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": ["todo.test_list"], + "status": ["completed"], + } + + # Test that the status filter is passed correctly to the service call. + # We don't assert on the response since it is fixed above. + calls.clear() + result = await tool.async_call( + hass, + llm.ToolInput( + "todo_get_items", + {"todo_list": "Mock Todo List Name", "status": "all"}, + ), + llm_context, + ) + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": ["todo.test_list"], + "status": ["needs_action", "completed"], + } + + async def test_no_tools_exposed(hass: HomeAssistant) -> None: """Test that tools are not exposed when no entities are exposed.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -1342,3 +1458,57 @@ async def test_no_tools_exposed(hass: HomeAssistant) -> None: ) api = await llm.async_get_api(hass, "assist", llm_context) assert api.tools == [] + + +async def test_merged_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Test an API instance that merges multiple llm apis.""" + + class MyTool(llm.Tool): + def __init__(self, name: str, description: str) -> None: + self.name = name + self.description = description + + async def async_call( + self, hass: HomeAssistant, tool_input: llm.ToolInput, _: llm.LLMContext + ) -> JsonObjectType: + return {"result": {tool_input.tool_name: tool_input.tool_args}} + + api1 = MyAPI(hass=hass, id="api-1", name="API 1") + api1.prompt = "This is prompt 1" + api1.tools = [MyTool(name="Tool_1", description="Description 1")] + llm.async_register_api(hass, api1) + + api2 = MyAPI(hass=hass, id="api-2", name="API 2") + api2.prompt = "This is prompt 2" + api2.tools = [MyTool(name="Tool_2", description="Description 2")] + llm.async_register_api(hass, api2) + + instance = await llm.async_get_api(hass, ["api-1", "api-2"], llm_context) + assert instance.api.id == "api-1|api-2" + + assert ( + instance.api_prompt + == """Follow these instructions for tools from "api-1": +This is prompt 1 + +Follow these instructions for tools from "api-2": +This is prompt 2 + +""" + ) + assert [(tool.name, tool.description) for tool in instance.tools] == [ + ("api-1.Tool_1", "Description 1"), + ("api-2.Tool_2", "Description 2"), + ] + + # The test tool returns back the provided arguments so we can verify + # the original tool is invoked with the correct tool name and args. + result = await instance.async_call_tool( + llm.ToolInput(tool_name="api-1.Tool_1", tool_args={"arg1": "value1"}) + ) + assert result == {"result": {"Tool_1": {"arg1": "value1"}}} + + result = await instance.async_call_tool( + llm.ToolInput(tool_name="api-2.Tool_2", tool_args={"arg2": "value2"}) + ) + assert result == {"result": {"Tool_2": {"arg2": "value2"}}} diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py index 3064b215f2f..46d84ea768d 100644 --- a/tests/helpers/test_network.py +++ b/tests/helpers/test_network.py @@ -538,7 +538,7 @@ async def test_get_url(hass: HomeAssistant) -> None: "homeassistant.helpers.network._get_request_host", return_value="example.com", ), - patch("homeassistant.components.http.current_request"), + patch("homeassistant.helpers.http.current_request"), ): assert get_url(hass, require_current_request=True) == "https://example.com" assert ( @@ -554,7 +554,7 @@ async def test_get_url(hass: HomeAssistant) -> None: "homeassistant.helpers.network._get_request_host", return_value="example.local", ), - patch("homeassistant.components.http.current_request"), + patch("homeassistant.helpers.http.current_request"), ): assert get_url(hass, require_current_request=True) == "http://example.local" @@ -592,7 +592,7 @@ async def test_get_request_host_with_port(hass: HomeAssistant) -> None: with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy( CIMultiDict({hdrs.HOST: "example.com:8123"}) @@ -609,7 +609,7 @@ async def test_get_request_host_without_port(hass: HomeAssistant) -> None: with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy(CIMultiDict({hdrs.HOST: "example.com"})) mock_request.url = URL("http://example.com/test/request") @@ -624,7 +624,7 @@ async def test_get_request_ipv6_address(hass: HomeAssistant) -> None: with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy(CIMultiDict({hdrs.HOST: "[::1]:8123"})) mock_request.url = URL("http://[::1]:8123/test/request") @@ -639,7 +639,7 @@ async def test_get_request_ipv6_address_without_port(hass: HomeAssistant) -> Non with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy(CIMultiDict({hdrs.HOST: "[::1]"})) mock_request.url = URL("http://[::1]/test/request") @@ -654,7 +654,7 @@ async def test_get_request_host_no_host_header(hass: HomeAssistant) -> None: with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy(CIMultiDict()) mock_request.url = URL("/test/request") diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 4c707590528..4a50cb9399f 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -6658,3 +6658,41 @@ async def test_calling_service_backwards_compatible( ], } ) + + +async def test_enabled_sequence_in_parallel( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test to ensure sequence inside parallel follows enabled tag.""" + event = "test_event" + events = async_capture_events(hass, event) + sequence = cv.SCRIPT_SCHEMA( + { + "parallel": [ + { + "sequence": [{"event": event, "event_data": {"value": "disabled"}}], + "enabled": "false", + }, + { + "sequence": [{"event": event, "event_data": {"value": "enabled"}}], + "enabled": "true", + }, + ] + } + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data["value"] == "enabled" + + expected_trace = { + "0": [{"result": {"enabled": False}}], + "0/parallel/1/sequence/0": [ + {"result": {"event": "test_event", "event_data": {"value": "enabled"}}} + ], + } + assert_action_trace(expected_trace) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 70ab20e87fa..4582bce3e05 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -49,6 +49,7 @@ from tests.common import ( MockEntity, MockModule, MockUser, + RegistryEntryWithDefaults, async_mock_service, mock_area_registry, mock_device_registry, @@ -158,94 +159,94 @@ def floor_area_mock(hass: HomeAssistant) -> None: }, ) - entity_in_own_area = er.RegistryEntry( + entity_in_own_area = RegistryEntryWithDefaults( entity_id="light.in_own_area", unique_id="in-own-area-id", platform="test", area_id="own-area", ) - config_entity_in_own_area = er.RegistryEntry( + config_entity_in_own_area = RegistryEntryWithDefaults( entity_id="light.config_in_own_area", unique_id="config-in-own-area-id", platform="test", area_id="own-area", entity_category=EntityCategory.CONFIG, ) - hidden_entity_in_own_area = er.RegistryEntry( + hidden_entity_in_own_area = RegistryEntryWithDefaults( entity_id="light.hidden_in_own_area", unique_id="hidden-in-own-area-id", platform="test", area_id="own-area", hidden_by=er.RegistryEntryHider.USER, ) - entity_in_area = er.RegistryEntry( + entity_in_area = RegistryEntryWithDefaults( entity_id="light.in_area", unique_id="in-area-id", platform="test", device_id=device_in_area.id, ) - config_entity_in_area = er.RegistryEntry( + config_entity_in_area = RegistryEntryWithDefaults( entity_id="light.config_in_area", unique_id="config-in-area-id", platform="test", device_id=device_in_area.id, entity_category=EntityCategory.CONFIG, ) - hidden_entity_in_area = er.RegistryEntry( + hidden_entity_in_area = RegistryEntryWithDefaults( entity_id="light.hidden_in_area", unique_id="hidden-in-area-id", platform="test", device_id=device_in_area.id, hidden_by=er.RegistryEntryHider.USER, ) - entity_in_other_area = er.RegistryEntry( + entity_in_other_area = RegistryEntryWithDefaults( entity_id="light.in_other_area", unique_id="in-area-a-id", platform="test", device_id=device_in_area.id, area_id="other-area", ) - entity_assigned_to_area = er.RegistryEntry( + entity_assigned_to_area = RegistryEntryWithDefaults( entity_id="light.assigned_to_area", unique_id="assigned-area-id", platform="test", device_id=device_in_area.id, area_id="test-area", ) - entity_no_area = er.RegistryEntry( + entity_no_area = RegistryEntryWithDefaults( entity_id="light.no_area", unique_id="no-area-id", platform="test", device_id=device_no_area.id, ) - config_entity_no_area = er.RegistryEntry( + config_entity_no_area = RegistryEntryWithDefaults( entity_id="light.config_no_area", unique_id="config-no-area-id", platform="test", device_id=device_no_area.id, entity_category=EntityCategory.CONFIG, ) - hidden_entity_no_area = er.RegistryEntry( + hidden_entity_no_area = RegistryEntryWithDefaults( entity_id="light.hidden_no_area", unique_id="hidden-no-area-id", platform="test", device_id=device_no_area.id, hidden_by=er.RegistryEntryHider.USER, ) - entity_diff_area = er.RegistryEntry( + entity_diff_area = RegistryEntryWithDefaults( entity_id="light.diff_area", unique_id="diff-area-id", platform="test", device_id=device_diff_area.id, ) - entity_in_area_a = er.RegistryEntry( + entity_in_area_a = RegistryEntryWithDefaults( entity_id="light.in_area_a", unique_id="in-area-a-id", platform="test", device_id=device_area_a.id, area_id="area-a", ) - entity_in_area_b = er.RegistryEntry( + entity_in_area_b = RegistryEntryWithDefaults( entity_id="light.in_area_b", unique_id="in-area-b-id", platform="test", @@ -329,53 +330,53 @@ def label_mock(hass: HomeAssistant) -> None: }, ) - entity_with_my_label = er.RegistryEntry( + entity_with_my_label = RegistryEntryWithDefaults( entity_id="light.with_my_label", unique_id="with_my_label", platform="test", labels={"my-label"}, ) - hidden_entity_with_my_label = er.RegistryEntry( + hidden_entity_with_my_label = RegistryEntryWithDefaults( entity_id="light.hidden_with_my_label", unique_id="hidden_with_my_label", platform="test", labels={"my-label"}, hidden_by=er.RegistryEntryHider.USER, ) - config_entity_with_my_label = er.RegistryEntry( + config_entity_with_my_label = RegistryEntryWithDefaults( entity_id="light.config_with_my_label", unique_id="config_with_my_label", platform="test", labels={"my-label"}, entity_category=EntityCategory.CONFIG, ) - entity_with_label1_from_device = er.RegistryEntry( + entity_with_label1_from_device = RegistryEntryWithDefaults( entity_id="light.with_label1_from_device", unique_id="with_label1_from_device", platform="test", device_id=device_has_label1.id, ) - entity_with_label1_from_device_and_different_area = er.RegistryEntry( + entity_with_label1_from_device_and_different_area = RegistryEntryWithDefaults( entity_id="light.with_label1_from_device_diff_area", unique_id="with_label1_from_device_diff_area", platform="test", device_id=device_has_label1.id, area_id=area_without_labels.id, ) - entity_with_label1_and_label2_from_device = er.RegistryEntry( + entity_with_label1_and_label2_from_device = RegistryEntryWithDefaults( entity_id="light.with_label1_and_label2_from_device", unique_id="with_label1_and_label2_from_device", platform="test", labels={"label1"}, device_id=device_has_label2.id, ) - entity_with_labels_from_device = er.RegistryEntry( + entity_with_labels_from_device = RegistryEntryWithDefaults( entity_id="light.with_labels_from_device", unique_id="with_labels_from_device", platform="test", device_id=device_has_labels.id, ) - entity_with_no_labels = er.RegistryEntry( + entity_with_no_labels = RegistryEntryWithDefaults( entity_id="light.no_labels", unique_id="no_labels", platform="test", @@ -1697,7 +1698,7 @@ async def test_domain_control_unauthorized( mock_registry( hass, { - "light.kitchen": er.RegistryEntry( + "light.kitchen": RegistryEntryWithDefaults( entity_id="light.kitchen", unique_id="kitchen", platform="test_domain", @@ -1738,7 +1739,7 @@ async def test_domain_control_admin( mock_registry( hass, { - "light.kitchen": er.RegistryEntry( + "light.kitchen": RegistryEntryWithDefaults( entity_id="light.kitchen", unique_id="kitchen", platform="test_domain", @@ -1776,7 +1777,7 @@ async def test_domain_control_no_user(hass: HomeAssistant) -> None: mock_registry( hass, { - "light.kitchen": er.RegistryEntry( + "light.kitchen": RegistryEntryWithDefaults( entity_id="light.kitchen", unique_id="kitchen", platform="test_domain", diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 89d1c307fd7..43efe79e96f 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -3887,6 +3887,66 @@ async def test_device_id( assert info.rate_limit is None +async def test_device_name( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test device_name function.""" + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + + # Test non existing entity id + info = render_to_info(hass, "{{ device_name('sensor.fake') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existing device id + info = render_to_info(hass, "{{ device_name('1234567890') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ device_name(56) }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test device with single entity + 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")}, + name="A light", + ) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + info = render_to_info(hass, f"{{{{ device_name('{device_entry.id}') }}}}") + assert_result_info(info, device_entry.name) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ device_name('{entity_entry.entity_id}') }}}}") + assert_result_info(info, device_entry.name) + assert info.rate_limit is None + + # Test device after renaming + device_entry = device_registry.async_update_device( + device_entry.id, + name_by_user="My light", + ) + + info = render_to_info(hass, f"{{{{ device_name('{device_entry.id}') }}}}") + assert_result_info(info, device_entry.name_by_user) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ device_name('{entity_entry.entity_id}') }}}}") + assert_result_info(info, device_entry.name_by_user) + assert info.rate_limit is None + + async def test_device_attr( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/helpers/test_trigger_template_entity.py b/tests/helpers/test_trigger_template_entity.py index a18827ecb4c..8389218054d 100644 --- a/tests/helpers/test_trigger_template_entity.py +++ b/tests/helpers/test_trigger_template_entity.py @@ -1,8 +1,82 @@ """Test template trigger entity.""" +from typing import Any + +import pytest + +from homeassistant.const import ( + CONF_ICON, + CONF_NAME, + CONF_STATE, + CONF_UNIQUE_ID, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + CONF_ATTRIBUTES, + CONF_AVAILABILITY, + CONF_PICTURE, + ManualTriggerEntity, + ValueTemplate, +) + +_ICON_TEMPLATE = 'mdi:o{{ "n" if value=="on" else "ff" }}' +_PICTURE_TEMPLATE = '/local/picture_o{{ "n" if value=="on" else "ff" }}' + + +@pytest.mark.parametrize( + ("value", "test_template", "error_value", "expected", "error"), + [ + (1, "{{ value == 1 }}", None, "True", None), + (1, "1", None, "1", None), + ( + 1, + "{{ x - 4 }}", + None, + None, + "", + ), + ( + 1, + "{{ x - 4 }}", + template._SENTINEL, + template._SENTINEL, + "Error parsing value for test.entity: 'x' is undefined (value: 1, template: {{ x - 4 }})", + ), + ], +) +async def test_value_template_object( + hass: HomeAssistant, + value: Any, + test_template: str, + error_value: Any, + expected: Any, + error: str | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test ValueTemplate object.""" + entity = ManualTriggerEntity( + hass, + { + CONF_NAME: template.Template("test_entity", hass), + }, + ) + entity.entity_id = "test.entity" + + value_template = ValueTemplate.from_template(template.Template(test_template, hass)) + + variables = entity._template_variables_with_value(value) + result = value_template.async_render_as_value_template( + entity.entity_id, variables, error_value + ) + + assert result == expected + + if error is not None: + assert error in caplog.text async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: @@ -20,21 +94,197 @@ async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: entity = ManualTriggerEntity(hass, config) entity.entity_id = "test.entity" - hass.states.async_set("test.entity", "on") + hass.states.async_set("test.entity", STATE_ON) await entity.async_added_to_hass() - entity._process_manual_data("on") + variables = entity._template_variables_with_value(STATE_ON) + entity._process_manual_data(variables) await hass.async_block_till_done() assert entity.name == "test_entity" assert entity.icon == "mdi:on" assert entity.entity_picture == "/local/picture_on" - hass.states.async_set("test.entity", "off") + hass.states.async_set("test.entity", STATE_OFF) await entity.async_added_to_hass() - entity._process_manual_data("off") + + variables = entity._template_variables_with_value(STATE_OFF) + entity._process_manual_data(variables) await hass.async_block_till_done() assert entity.name == "test_entity" assert entity.icon == "mdi:off" assert entity.entity_picture == "/local/picture_off" + + +@pytest.mark.parametrize( + ("test_template", "test_entity_state", "expected"), + [ + ('{{ has_value("test.entity") }}', STATE_ON, True), + ('{{ has_value("test.entity") }}', STATE_OFF, True), + ('{{ has_value("test.entity") }}', STATE_UNKNOWN, False), + ('{{ "a" if has_value("test.entity") else "b" }}', STATE_ON, False), + ('{{ "something_not_boolean" }}', STATE_OFF, False), + ("{{ 1 }}", STATE_OFF, True), + ("{{ 0 }}", STATE_OFF, False), + ], +) +async def test_trigger_template_availability( + hass: HomeAssistant, + test_template: str, + test_entity_state: str, + expected: bool, +) -> None: + """Test manual trigger template entity availability template.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_AVAILABILITY: template.Template(test_template, hass), + CONF_UNIQUE_ID: "9961786c-f8c8-4ea0-ab1d-b9e922c39088", + } + + entity = ManualTriggerEntity(hass, config) + entity.entity_id = "test.entity" + hass.states.async_set("test.entity", test_entity_state) + await entity.async_added_to_hass() + + variables = entity._template_variables() + assert entity._render_availability_template(variables) is expected + await hass.async_block_till_done() + + assert entity.unique_id == "9961786c-f8c8-4ea0-ab1d-b9e922c39088" + assert entity.available is expected + + +async def test_trigger_no_availability_template( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test manual trigger template entity when availability template isn't used.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ICON: template.Template(_ICON_TEMPLATE, hass), + CONF_PICTURE: template.Template(_PICTURE_TEMPLATE, hass), + CONF_STATE: template.Template("{{ value == 'on' }}", hass), + } + + class TestEntity(ManualTriggerEntity): + """Test entity class.""" + + extra_template_keys = (CONF_STATE,) + + @property + def state(self) -> bool | None: + """Return extra attributes.""" + return self._rendered.get(CONF_STATE) + + entity = TestEntity(hass, config) + entity.entity_id = "test.entity" + variables = entity._template_variables_with_value(STATE_ON) + assert entity._render_availability_template(variables) is True + assert entity.available is True + entity._process_manual_data(variables) + await hass.async_block_till_done() + + assert entity.state == "True" + assert entity.icon == "mdi:on" + assert entity.entity_picture == "/local/picture_on" + + variables = entity._template_variables_with_value(STATE_OFF) + assert entity._render_availability_template(variables) is True + assert entity.available is True + entity._process_manual_data(variables) + await hass.async_block_till_done() + + assert entity.state == "False" + assert entity.icon == "mdi:off" + assert entity.entity_picture == "/local/picture_off" + + +async def test_trigger_template_availability_with_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test manual trigger template entity when availability render fails.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_AVAILABILITY: template.Template("{{ incorrect ", hass), + } + + entity = ManualTriggerEntity(hass, config) + entity.entity_id = "test.entity" + + variables = entity._template_variables() + entity._render_availability_template(variables) + assert entity.available is True + + assert "Error rendering availability template for test.entity" in caplog.text + + +async def test_attribute_order( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test manual trigger template entity when availability render fails.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ATTRIBUTES: { + "beer": template.Template("{{ value }}", hass), + "no_beer": template.Template("{{ sad - 1 }}", hass), + "more_beer": template.Template("{{ beer + 1 }}", hass), + }, + } + + entity = ManualTriggerEntity(hass, config) + entity.entity_id = "test.entity" + hass.states.async_set("test.entity", STATE_ON) + await entity.async_added_to_hass() + + variables = entity._template_variables_with_value(1) + entity._process_manual_data(variables) + await hass.async_block_till_done() + + assert entity.extra_state_attributes == {"beer": 1, "more_beer": 2} + + assert ( + "Error rendering attributes.no_beer template for test.entity: UndefinedError: 'sad' is undefined" + in caplog.text + ) + + +async def test_trigger_template_complex(hass: HomeAssistant) -> None: + """Test manual trigger template entity complex template.""" + complex_template = """ + {% set d = {'test_key':'test_data'} %} + {{ dict(d) }} + +""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ICON: template.Template( + '{% if value=="on" %} mdi:on {% else %} mdi:off {% endif %}', hass + ), + CONF_PICTURE: template.Template( + '{% if value=="on" %} /local/picture_on {% else %} /local/picture_off {% endif %}', + hass, + ), + CONF_AVAILABILITY: template.Template('{{ has_value("test.entity") }}', hass), + "other_key": template.Template(complex_template, hass), + } + + class TestEntity(ManualTriggerEntity): + """Test entity class.""" + + extra_template_keys_complex = ("other_key",) + + @property + def some_other_key(self) -> dict[str, Any] | None: + """Return extra attributes.""" + return self._rendered.get("other_key") + + entity = TestEntity(hass, config) + entity.entity_id = "test.entity" + hass.states.async_set("test.entity", STATE_ON) + await entity.async_added_to_hass() + + variables = entity._template_variables_with_value(STATE_ON) + entity._process_manual_data(variables) + await hass.async_block_till_done() + + assert entity.some_other_key == {"test_key": "test_data"} diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index efa3ca9523a..c9748cc61f8 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable import re from types import ModuleType from unittest.mock import patch @@ -375,12 +376,11 @@ def test_invalid_config_flow_step( type_hint_checker.visit_classdef(class_node) -def test_invalid_custom_config_flow_step( - linter: UnittestLinter, type_hint_checker: BaseChecker -) -> None: - """Ensure invalid hints are rejected for ConfigFlow step.""" - class_node, func_node, arg_node = astroid.extract_node( - """ +@pytest.mark.parametrize( + ("code", "expected_messages_fn"), + [ + ( + """ class FlowHandler(): pass @@ -392,34 +392,79 @@ def test_invalid_custom_config_flow_step( ): async def async_step_axis_specific( #@ self, - device_config: dict #@ + device_config: dict ): pass - """, +""", + lambda func_node: [ + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=func_node, + args=("ConfigFlowResult", "async_step_axis_specific"), + line=11, + col_offset=4, + end_line=11, + end_col_offset=38, + ), + ], + ), + ( + """ + class FlowHandler(): + pass + + class ConfigSubentryFlow(FlowHandler): + pass + + class CustomSubentryFlowHandler(ConfigSubentryFlow): #@ + async def async_step_user( #@ + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + pass +""", + lambda func_node: [ + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=func_node, + args=("SubentryFlowResult", "async_step_user"), + line=9, + col_offset=4, + end_line=9, + end_col_offset=29, + ), + ], + ), + ], + ids=[ + "Config flow", + "Config subentry flow", + ], +) +def test_invalid_flow_step( + linter: UnittestLinter, + type_hint_checker: BaseChecker, + code: str, + expected_messages_fn: Callable[ + [astroid.NodeNG], tuple[pylint.testutils.MessageTest, ...] + ], +) -> None: + """Ensure invalid hints are rejected for flow step.""" + class_node, func_node = astroid.extract_node( + code, "homeassistant.components.pylint_test.config_flow", ) type_hint_checker.visit_module(class_node.parent) with assert_adds_messages( linter, - pylint.testutils.MessageTest( - msg_id="hass-return-type", - node=func_node, - args=("ConfigFlowResult", "async_step_axis_specific"), - line=11, - col_offset=4, - end_line=11, - end_col_offset=38, - ), + *expected_messages_fn(func_node), ): type_hint_checker.visit_classdef(class_node) -def test_valid_config_flow_step( - linter: UnittestLinter, type_hint_checker: BaseChecker -) -> None: - """Ensure valid hints are accepted for ConfigFlow step.""" - class_node = astroid.extract_node( +@pytest.mark.parametrize( + "code", + [ """ class FlowHandler(): pass @@ -436,6 +481,33 @@ def test_valid_config_flow_step( ) -> ConfigFlowResult: pass """, + """ + class FlowHandler(): + pass + + class ConfigSubentryFlow(FlowHandler): + pass + + class CustomSubentryFlowHandler(ConfigSubentryFlow): #@ + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + pass +""", + ], + ids=[ + "Config flow", + "Config subentry flow", + ], +) +def test_valid_flow_step( + linter: UnittestLinter, + type_hint_checker: BaseChecker, + code: str, +) -> None: + """Ensure valid hints are accepted for flow step.""" + class_node = astroid.extract_node( + code, "homeassistant.components.pylint_test.config_flow", ) type_hint_checker.visit_module(class_node.parent) diff --git a/tests/scripts/test_auth.py b/tests/scripts/test_auth.py index e52a2cc6567..e9b6f4f718f 100644 --- a/tests/scripts/test_auth.py +++ b/tests/scripts/test_auth.py @@ -26,12 +26,10 @@ def reset_log_level() -> Generator[None]: @pytest.fixture -def provider(hass: HomeAssistant) -> hass_auth.HassAuthProvider: +async def provider(hass: HomeAssistant) -> hass_auth.HassAuthProvider: """Home Assistant auth provider.""" - provider = hass.loop.run_until_complete( - register_auth_provider(hass, {"type": "homeassistant"}) - ) - hass.loop.run_until_complete(provider.async_initialize()) + provider = await register_auth_provider(hass, {"type": "homeassistant"}) + await provider.async_initialize() return provider diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 1fb87ac5ef6..ebfc6b81e00 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -252,8 +252,8 @@ async def test_setup_after_deps_all_present(hass: HomeAssistant) -> None: @pytest.mark.parametrize("load_registries", [False]) -async def test_setup_after_deps_in_stage_1_ignored(hass: HomeAssistant) -> None: - """Test after_dependencies are ignored in stage 1.""" +async def test_setup_after_deps_in_stage_1(hass: HomeAssistant) -> None: + """Test after_dependencies are promoted in stage 1.""" # This test relies on this assert "cloud" in bootstrap.STAGE_1_INTEGRATIONS order = [] @@ -295,7 +295,7 @@ async def test_setup_after_deps_in_stage_1_ignored(hass: HomeAssistant) -> None: assert "normal_integration" in hass.config.components assert "cloud" in hass.config.components - assert order == ["cloud", "an_after_dep", "normal_integration"] + assert order == ["an_after_dep", "normal_integration", "cloud"] @pytest.mark.parametrize("load_registries", [False]) @@ -304,7 +304,7 @@ async def test_setup_after_deps_manifests_are_loaded_even_if_not_setup( ) -> None: """Ensure we preload manifests for after deps even if they are not setup. - Its important that we preload the after dep manifests even if they are not setup + It's important that we preload the after dep manifests even if they are not setup since we will always have to check their requirements since any integration that lists an after dep may import it and we have to ensure requirements are up to date before the after dep can be imported. @@ -371,7 +371,7 @@ async def test_setup_after_deps_manifests_are_loaded_even_if_not_setup( assert "an_after_dep" not in hass.config.components assert "an_after_dep_of_after_dep" not in hass.config.components assert "an_after_dep_of_after_dep_of_after_dep" not in hass.config.components - assert order == ["cloud", "normal_integration"] + assert order == ["normal_integration", "cloud"] assert loader.async_get_loaded_integration(hass, "an_after_dep") is not None assert ( loader.async_get_loaded_integration(hass, "an_after_dep_of_after_dep") @@ -456,9 +456,9 @@ async def test_setup_frontend_before_recorder(hass: HomeAssistant) -> None: assert order == [ "http", + "an_after_dep", "frontend", "recorder", - "an_after_dep", "normal_integration", ] @@ -703,8 +703,8 @@ async def test_setup_hass_takes_longer_than_log_slow_startup( return True with ( - patch.object(bootstrap, "LOG_SLOW_STARTUP_INTERVAL", 0.1), - patch.object(bootstrap, "SLOW_STARTUP_CHECK_INTERVAL", 0.05), + patch.object(bootstrap, "LOG_SLOW_STARTUP_INTERVAL", 0.005), + patch.object(bootstrap, "SLOW_STARTUP_CHECK_INTERVAL", 0.005), patch( "homeassistant.components.frontend.async_setup", side_effect=_async_setup_that_blocks_startup, @@ -924,7 +924,7 @@ async def test_setup_hass_invalid_core_config( "external_url": "https://abcdef.ui.nabu.casa", }, "map": {}, - "person": {"invalid": True}, + "frontend": {"invalid": True}, } ], ) @@ -1560,6 +1560,11 @@ async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> # we remove the platform YAML schema support for sensors "websocket_api": {"sensor.py"}, } + # person is a special case because it is a base platform + # in the sense that it creates entities in its namespace + # but its not used by other integrations to create entities + # so we want to make sure it is not loaded before the recorder + base_platforms = BASE_PLATFORMS | {"person"} integrations_before_recorder: set[str] = set() for _, integrations, _ in bootstrap.STAGE_0_INTEGRATIONS: @@ -1577,8 +1582,10 @@ async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> assert not isinstance(integrations_or_excs, Exception) integrations[domain] = integration - integrations_all_dependencies = await loader.resolve_integrations_dependencies( - hass, integrations.values() + integrations_all_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, integrations.values(), ignore_exceptions=True + ) ) all_integrations = integrations.copy() all_integrations.update( @@ -1590,7 +1597,7 @@ async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> problems: dict[str, set[str]] = {} for domain in integrations: domain_with_base_platforms_deps = ( - integrations_all_dependencies[domain] & BASE_PLATFORMS + integrations_all_dependencies[domain] & base_platforms ) if domain_with_base_platforms_deps: problems[domain] = domain_with_base_platforms_deps @@ -1598,7 +1605,7 @@ async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> f"Integrations that are setup before recorder have base platforms in their dependencies: {problems}" ) - base_platform_py_files = {f"{base_platform}.py" for base_platform in BASE_PLATFORMS} + base_platform_py_files = {f"{base_platform}.py" for base_platform in base_platforms} for domain, integration in all_integrations.items(): integration_base_platforms_files = ( diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index b38fed60e85..ba599c88518 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -57,7 +57,6 @@ from .common import ( MockPlatform, async_capture_events, async_fire_time_changed, - async_get_persistent_notifications, flush_store, mock_config_flow, mock_integration, @@ -696,7 +695,7 @@ async def test_remove_entry_cancels_reauth( manager: config_entries.ConfigEntries, issue_registry: ir.IssueRegistry, ) -> None: - """Tests that removing a config entry, also aborts existing reauth flows.""" + """Tests that removing a config entry also aborts existing reauth flows.""" entry = MockConfigEntry(title="test_title", domain="test") mock_setup_entry = AsyncMock(side_effect=ConfigEntryAuthFailed()) @@ -723,6 +722,40 @@ async def test_remove_entry_cancels_reauth( assert not issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) +async def test_reload_entry_cancels_reauth( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + issue_registry: ir.IssueRegistry, +) -> None: + """Tests that reloading a config entry also aborts existing reauth flows.""" + entry = MockConfigEntry(title="test_title", domain="test") + + mock_setup_entry = AsyncMock(side_effect=ConfigEntryAuthFailed()) + mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) + mock_platform(hass, "test.config_flow", None) + + entry.add_to_hass(hass) + await manager.async_setup(entry.entry_id) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress_by_handler("test") + assert len(flows) == 1 + assert flows[0]["context"]["entry_id"] == entry.entry_id + assert flows[0]["context"]["source"] == config_entries.SOURCE_REAUTH + assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + issue_id = f"config_entry_reauth_test_{entry.entry_id}" + assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) + + mock_setup_entry.return_value = True + mock_setup_entry.side_effect = None + await manager.async_reload(entry.entry_id) + + flows = hass.config_entries.flow.async_progress_by_handler("test") + assert len(flows) == 0 + assert not issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) + + async def test_remove_entry_handles_callback_error( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -1368,64 +1401,42 @@ async def test_async_forward_entry_setup_deprecated( ) in caplog.text -async def test_discovery_notification( - hass: HomeAssistant, manager: config_entries.ConfigEntries -) -> None: - """Test that we create/dismiss a notification when source is discovery.""" - mock_integration(hass, MockModule("test")) - mock_platform(hass, "test.config_flow", None) - - class TestFlow(config_entries.ConfigFlow): - """Test flow.""" - - VERSION = 5 - - async def async_step_discovery(self, discovery_info): - """Test discovery step.""" - return self.async_show_form(step_id="discovery_confirm") - - async def async_step_discovery_confirm(self, discovery_info): - """Test discovery confirm step.""" - return self.async_create_entry(title="Test Title", data={"token": "abcd"}) - - with mock_config_flow("test", TestFlow): - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" not in notifications - - # Start first discovery flow to assert that discovery notification fires - flow1 = await hass.config_entries.flow.async_init( - "test", context={"source": config_entries.SOURCE_DISCOVERY} - ) - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" in notifications - - # Start a second discovery flow so we can finish the first and assert that - # the discovery notification persists until the second one is complete - flow2 = await hass.config_entries.flow.async_init( - "test", context={"source": config_entries.SOURCE_DISCOVERY} - ) - - flow1 = await hass.config_entries.flow.async_configure(flow1["flow_id"], {}) - assert flow1["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" in notifications - - flow2 = await hass.config_entries.flow.async_configure(flow2["flow_id"], {}) - assert flow2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" not in notifications - - -async def test_reauth_issue( +async def test_reauth_issue_flow_returns_abort( hass: HomeAssistant, manager: config_entries.ConfigEntries, issue_registry: ir.IssueRegistry, ) -> None: + """Test that we create/delete an issue when source is reauth. + + In this test, the reauth flow returns abort. + """ + issue = await _test_reauth_issue(hass, manager, issue_registry) + + result = await manager.flow.async_configure(issue.data["flow_id"], {}) + assert result["type"] == FlowResultType.ABORT + assert len(issue_registry.issues) == 0 + + +async def test_reauth_issue_flow_aborted( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + issue_registry: ir.IssueRegistry, +) -> None: + """Test that we create/delete an issue when source is reauth. + + In this test, the reauth flow is aborted. + """ + issue = await _test_reauth_issue(hass, manager, issue_registry) + + manager.flow.async_abort(issue.data["flow_id"]) + assert len(issue_registry.issues) == 0 + + +async def _test_reauth_issue( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + issue_registry: ir.IssueRegistry, +) -> ir.IssueEntry: """Test that we create/delete an issue when source is reauth.""" assert len(issue_registry.issues) == 0 @@ -1461,34 +1472,7 @@ async def test_reauth_issue( translation_key="config_entry_reauth", translation_placeholders={"name": "test_title"}, ) - - result = await hass.config_entries.flow.async_configure(issue.data["flow_id"], {}) - assert result["type"] == FlowResultType.ABORT - assert len(issue_registry.issues) == 0 - - -async def test_discovery_notification_not_created(hass: HomeAssistant) -> None: - """Test that we not create a notification when discovery is aborted.""" - mock_integration(hass, MockModule("test")) - mock_platform(hass, "test.config_flow", None) - - class TestFlow(config_entries.ConfigFlow): - """Test flow.""" - - VERSION = 5 - - async def async_step_discovery(self, discovery_info): - """Test discovery step.""" - return self.async_abort(reason="test") - - with mock_config_flow("test", TestFlow): - await hass.config_entries.flow.async_init( - "test", context={"source": config_entries.SOURCE_DISCOVERY} - ) - - await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_discovery") - assert state is None + return issue async def test_loading_default_config(hass: HomeAssistant) -> None: @@ -3275,7 +3259,9 @@ async def test_unique_id_update_existing_entry_without_reload( """Test user step.""" await self.async_set_unique_id("mock-unique-id") self._abort_if_unique_id_configured( - updates={"host": "1.1.1.1"}, reload_on_update=False + updates={"host": "1.1.1.1"}, + reload_on_update=False, + description_placeholders={"title": "Other device"}, ) with ( @@ -3291,6 +3277,7 @@ async def test_unique_id_update_existing_entry_without_reload( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + assert result["description_placeholders"]["title"] == "Other device" assert entry.data["host"] == "1.1.1.1" assert entry.data["additional"] == "data" assert len(async_reload.mock_calls) == 0 @@ -3325,7 +3312,9 @@ async def test_unique_id_update_existing_entry_with_reload( """Test user step.""" await self.async_set_unique_id("mock-unique-id") await self._abort_if_unique_id_configured( - updates=updates, reload_on_update=True + updates=updates, + reload_on_update=True, + description_placeholders={"title": "Other device"}, ) with ( @@ -3341,6 +3330,7 @@ async def test_unique_id_update_existing_entry_with_reload( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + assert result["description_placeholders"]["title"] == "Other device" assert entry.data["host"] == "1.1.1.1" assert entry.data["additional"] == "data" assert len(async_reload.mock_calls) == 1 @@ -3361,6 +3351,7 @@ async def test_unique_id_update_existing_entry_with_reload( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + assert result["description_placeholders"]["title"] == "Other device" assert entry.data["host"] == "2.2.2.2" assert entry.data["additional"] == "data" assert len(async_reload.mock_calls) == 0 @@ -4188,10 +4179,6 @@ async def test_partial_flows_hidden( # While it's blocked it shouldn't be visible or trigger discovery notifications assert len(hass.config_entries.flow.async_progress()) == 0 - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" not in notifications - # Let the flow init complete pause_discovery.set() @@ -4201,10 +4188,6 @@ async def test_partial_flows_hidden( assert result["type"] == data_entry_flow.FlowResultType.FORM assert len(hass.config_entries.flow.async_progress()) == 1 - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" in notifications - async def test_async_setup_init_entry( hass: HomeAssistant, manager: config_entries.ConfigEntries @@ -8482,41 +8465,6 @@ async def test_async_update_entry_unique_id_collision( assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) -@pytest.mark.parametrize("domain", ["flipr"]) -async def test_async_update_entry_unique_id_collision_allowed_domain( - hass: HomeAssistant, - manager: config_entries.ConfigEntries, - caplog: pytest.LogCaptureFixture, - issue_registry: ir.IssueRegistry, - domain: str, -) -> None: - """Test we warn when async_update_entry creates a unique_id collision. - - This tests we don't warn and don't create issues for domains which have - their own migration path. - """ - assert len(issue_registry.issues) == 0 - - entry1 = MockConfigEntry(domain=domain, unique_id=None) - entry2 = MockConfigEntry(domain=domain, unique_id="not none") - entry3 = MockConfigEntry(domain=domain, unique_id="very unique") - entry4 = MockConfigEntry(domain=domain, unique_id="also very unique") - entry1.add_to_manager(manager) - entry2.add_to_manager(manager) - entry3.add_to_manager(manager) - entry4.add_to_manager(manager) - - manager.async_update_entry(entry2, unique_id=None) - assert len(issue_registry.issues) == 0 - assert len(caplog.record_tuples) == 0 - - manager.async_update_entry(entry4, unique_id="very unique") - assert len(issue_registry.issues) == 0 - assert len(caplog.record_tuples) == 0 - - assert ("already in use") not in caplog.text - - async def test_unique_id_collision_issues( hass: HomeAssistant, manager: config_entries.ConfigEntries, diff --git a/tests/test_core.py b/tests/test_core.py index ceab3ce327c..50f7f92727b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -35,6 +35,7 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, EVENT_STATE_REPORTED, MATCH_ALL, + STATE_UNKNOWN, ) from homeassistant.core import ( CoreState, @@ -1368,9 +1369,6 @@ def test_state_init() -> None: with pytest.raises(InvalidEntityFormatError): ha.State("invalid_entity_format", "test_state") - with pytest.raises(InvalidStateError): - ha.State("domain.long_state", "t" * 256) - def test_state_domain() -> None: """Test domain.""" @@ -1440,6 +1438,38 @@ def test_state_repr() -> None: ) +async def test_statemachine_async_set_invalid_state(hass: HomeAssistant) -> None: + """Test setting an invalid state with the async_set method.""" + with pytest.raises( + InvalidStateError, + match="Invalid state with length 256. State max length is 255 characters.", + ): + hass.states.async_set("light.bowl", "o" * 256, {}) + + +async def test_statemachine_async_set_internal_invalid_state( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test setting an invalid state with the async_set_internal method.""" + long_state = "o" * 256 + hass.states.async_set_internal( + "light.bowl", + long_state, + {}, + force_update=False, + context=None, + state_info=None, + timestamp=time.time(), + ) + assert hass.states.get("light.bowl").state == STATE_UNKNOWN + assert ( + "homeassistant.core", + logging.ERROR, + f"State {long_state} for light.bowl is longer than 255, " + f"falling back to {STATE_UNKNOWN}", + ) in caplog.record_tuples + + async def test_statemachine_is_state(hass: HomeAssistant) -> None: """Test is_state method.""" hass.states.async_set("light.bowl", "on", {}) diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 86ba5257001..961afd69c2d 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -210,6 +210,21 @@ async def test_abort_removes_instance(manager: MockFlowManager) -> None: assert len(manager.mock_created_entries) == 0 +async def test_abort_aborted_flow(manager: MockFlowManager) -> None: + """Test return abort from aborted flow.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + manager.async_abort(self.flow_id) + return self.async_abort(reason="blah") + + form = await manager.async_init("test") + assert form["reason"] == "blah" + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 + + async def test_abort_calls_async_remove(manager: MockFlowManager) -> None: """Test abort calling the async_remove FlowHandler method.""" @@ -228,6 +243,23 @@ async def test_abort_calls_async_remove(manager: MockFlowManager) -> None: assert len(manager.mock_created_entries) == 0 +async def test_abort_calls_async_flow_removed(manager: MockFlowManager) -> None: + """Test abort calling the async_flow_removed FlowManager method.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + return self.async_abort(reason="reason") + + manager.async_flow_removed = Mock() + await manager.async_init("test") + + manager.async_flow_removed.assert_called_once() + + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 + + async def test_abort_calls_async_remove_with_exception( manager: MockFlowManager, caplog: pytest.LogCaptureFixture ) -> None: @@ -272,6 +304,42 @@ async def test_create_saves_data(manager: MockFlowManager) -> None: assert entry["source"] is None +async def test_create_aborted_flow(manager: MockFlowManager) -> None: + """Test return create_entry from aborted flow.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + + async def async_step_init(self, user_input=None): + manager.async_abort(self.flow_id) + return self.async_create_entry(title="Test Title", data="Test Data") + + with pytest.raises(data_entry_flow.UnknownFlow): + await manager.async_init("test") + assert len(manager.async_progress()) == 0 + + # No entry should be created if the flow is aborted + assert len(manager.mock_created_entries) == 0 + + +async def test_create_calls_async_flow_removed(manager: MockFlowManager) -> None: + """Test create calling the async_flow_removed FlowManager method.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + return self.async_create_entry(title="Test Title", data="Test Data") + + manager.async_flow_removed = Mock() + await manager.async_init("test") + + manager.async_flow_removed.assert_called_once() + + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 1 + + async def test_discovery_init_flow(manager: MockFlowManager) -> None: """Test a flow initialized by discovery.""" @@ -396,6 +464,9 @@ async def test_show_progress(hass: HomeAssistant, manager: MockFlowManager) -> N """Test show progress logic.""" manager.hass = hass events = [] + progress_update_events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE + ) task_one_evt = asyncio.Event() task_two_evt = asyncio.Event() event_received_evt = asyncio.Event() @@ -418,7 +489,9 @@ async def test_show_progress(hass: HomeAssistant, manager: MockFlowManager) -> N await task_one_evt.wait() async def long_running_job_two() -> None: + self.async_update_progress(0.25) await task_two_evt.wait() + self.async_update_progress(0.75) self.data = {"title": "Hello"} uncompleted_task: asyncio.Task[None] | None = None @@ -477,6 +550,12 @@ async def test_show_progress(hass: HomeAssistant, manager: MockFlowManager) -> N result = await manager.async_configure(result["flow_id"]) assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "task_two" + assert len(progress_update_events) == 1 + assert progress_update_events[0].data == { + "handler": "test", + "flow_id": result["flow_id"], + "progress": 0.25, + } # Set task two done and wait for event task_two_evt.set() @@ -488,6 +567,12 @@ async def test_show_progress(hass: HomeAssistant, manager: MockFlowManager) -> N "flow_id": result["flow_id"], "refresh": True, } + assert len(progress_update_events) == 2 + assert progress_update_events[1].data == { + "handler": "test", + "flow_id": result["flow_id"], + "progress": 0.75, + } # Frontend refreshes the flow result = await manager.async_configure(result["flow_id"]) @@ -884,12 +969,34 @@ async def test_configure_raises_unknown_flow_if_not_in_progress( await manager.async_configure("wrong_flow_id") -async def test_abort_raises_unknown_flow_if_not_in_progress( +async def test_manager_abort_raises_unknown_flow_if_not_in_progress( manager: MockFlowManager, ) -> None: """Test abort raises UnknownFlow if the flow is not in progress.""" with pytest.raises(data_entry_flow.UnknownFlow): - await manager.async_abort("wrong_flow_id") + manager.async_abort("wrong_flow_id") + + +async def test_manager_abort_calls_async_flow_removed(manager: MockFlowManager) -> None: + """Test abort calling the async_flow_removed FlowManager method.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + return self.async_show_form(step_id="init") + + manager.async_flow_removed = Mock() + result = await manager.async_init("test") + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + manager.async_flow_removed.assert_not_called() + + manager.async_abort(result["flow_id"]) + manager.async_flow_removed.assert_called_once() + + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 @pytest.mark.parametrize( diff --git a/tests/test_loader.py b/tests/test_loader.py index 0b83ddee3ea..16515cbd4e6 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -12,13 +12,13 @@ from awesomeversion import AwesomeVersion import pytest from homeassistant import loader -from homeassistant.components import http, hue +from homeassistant.components import hue from homeassistant.components.hue import light as hue_light -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.json import json_dumps from homeassistant.util.json import json_loads -from .common import MockModule, async_get_persistent_notifications, mock_integration +from .common import MockModule, mock_integration async def test_circular_component_dependencies(hass: HomeAssistant) -> None: @@ -29,25 +29,25 @@ async def test_circular_component_dependencies(hass: HomeAssistant) -> None: mod_4 = mock_integration(hass, MockModule("mod4", dependencies=["mod2", "mod3"])) all_domains = {"mod1", "mod2", "mod3", "mod4"} - deps = await loader._do_resolve_dependencies(mod_4, cache={}) + deps = await loader._resolve_integration_dependencies(mod_4, cache={}) assert deps == {"mod1", "mod2", "mod3"} # Create a circular dependency mock_integration(hass, MockModule("mod1", dependencies=["mod4"])) with pytest.raises(loader.CircularDependency): - await loader._do_resolve_dependencies(mod_4, cache={}) + await loader._resolve_integration_dependencies(mod_4, cache={}) # Create a different circular dependency mock_integration(hass, MockModule("mod1", dependencies=["mod3"])) with pytest.raises(loader.CircularDependency): - await loader._do_resolve_dependencies(mod_4, cache={}) + await loader._resolve_integration_dependencies(mod_4, cache={}) # Create a circular after_dependency mock_integration( hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod4"]}) ) with pytest.raises(loader.CircularDependency): - await loader._do_resolve_dependencies( + await loader._resolve_integration_dependencies( mod_4, cache={}, possible_after_dependencies=all_domains, @@ -58,7 +58,7 @@ async def test_circular_component_dependencies(hass: HomeAssistant) -> None: hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod3"]}) ) with pytest.raises(loader.CircularDependency): - await loader._do_resolve_dependencies( + await loader._resolve_integration_dependencies( mod_4, cache={}, possible_after_dependencies=all_domains, @@ -72,7 +72,7 @@ async def test_circular_component_dependencies(hass: HomeAssistant) -> None: hass, MockModule("mod4", partial_manifest={"after_dependencies": ["mod2"]}) ) with pytest.raises(loader.CircularDependency): - await loader._do_resolve_dependencies( + await loader._resolve_integration_dependencies( mod_4, cache={}, possible_after_dependencies=all_domains, @@ -114,48 +114,6 @@ async def test_nonexistent_component_dependencies(hass: HomeAssistant) -> None: assert result == {} -def test_component_loader(hass: HomeAssistant) -> None: - """Test loading components.""" - components = loader.Components(hass) - assert components.http.CONFIG_SCHEMA is http.CONFIG_SCHEMA - assert hass.components.http.CONFIG_SCHEMA is http.CONFIG_SCHEMA - - -def test_component_loader_non_existing(hass: HomeAssistant) -> None: - """Test loading components.""" - components = loader.Components(hass) - with pytest.raises(ImportError): - _ = components.non_existing - - -async def test_component_wrapper(hass: HomeAssistant) -> None: - """Test component wrapper.""" - components = loader.Components(hass) - components.persistent_notification.async_create("message") - - notifications = async_get_persistent_notifications(hass) - assert len(notifications) - - -async def test_helpers_wrapper(hass: HomeAssistant) -> None: - """Test helpers wrapper.""" - helpers = loader.Helpers(hass) - - result = [] - - @callback - def discovery_callback(service, discovered): - """Handle discovery callback.""" - result.append(discovered) - - helpers.discovery.async_listen("service_name", discovery_callback) - - await helpers.discovery.async_discover("service_name", "hello", None, {}) - await hass.async_block_till_done() - - assert result == ["hello"] - - @pytest.mark.usefixtures("enable_custom_integrations") async def test_custom_component_name(hass: HomeAssistant) -> None: """Test the name attribute of custom components.""" @@ -168,10 +126,6 @@ async def test_custom_component_name(hass: HomeAssistant) -> None: assert int_comp.__name__ == "custom_components.test_package" assert int_comp.__package__ == "custom_components.test_package" - comp = hass.components.test_package - assert comp.__name__ == "custom_components.test_package" - assert comp.__package__ == "custom_components.test_package" - integration = await loader.async_get_integration(hass, "test") platform = integration.get_platform("light") assert integration.get_platform_cached("light") is platform @@ -1349,42 +1303,6 @@ async def test_config_folder_not_in_path() -> None: import tests.testing_config.check_config_not_in_path # noqa: F401 -@pytest.mark.parametrize( - ("integration_frame_path", "expected"), - [ - pytest.param( - "custom_components/test_integration_frame", True, id="custom integration" - ), - pytest.param( - "homeassistant/components/test_integration_frame", - False, - id="core integration", - ), - pytest.param("homeassistant/test_integration_frame", False, id="core"), - ], -) -@pytest.mark.usefixtures("mock_integration_frame") -async def test_hass_components_use_reported( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - expected: bool, -) -> None: - """Test whether use of hass.components is reported.""" - with ( - patch( - "homeassistant.components.http.start_http_server_and_save_config", - return_value=None, - ), - ): - await hass.components.http.start_http_server_and_save_config(hass, [], None) - - reported = ( - "Detected that custom integration 'test_integration_frame'" - " accesses hass.components.http, which should be updated" - ) in caplog.text - assert reported == expected - - async def test_async_get_component_preloads_config_and_config_flow( hass: HomeAssistant, ) -> None: @@ -2044,42 +1962,6 @@ async def test_has_services(hass: HomeAssistant) -> None: assert integration.has_services is True -@pytest.mark.parametrize( - ("integration_frame_path", "expected"), - [ - pytest.param( - "custom_components/test_integration_frame", True, id="custom integration" - ), - pytest.param( - "homeassistant/components/test_integration_frame", - False, - id="core integration", - ), - pytest.param("homeassistant/test_integration_frame", False, id="core"), - ], -) -@pytest.mark.usefixtures("mock_integration_frame") -async def test_hass_helpers_use_reported( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - expected: bool, -) -> None: - """Test whether use of hass.helpers is reported.""" - with ( - patch( - "homeassistant.helpers.aiohttp_client.async_get_clientsession", - return_value=None, - ), - ): - hass.helpers.aiohttp_client.async_get_clientsession() - - reported = ( - "Detected that custom integration 'test_integration_frame' " - "accesses hass.helpers.aiohttp_client, which should be updated" - ) in caplog.text - assert reported == expected - - async def test_manifest_json_fragment_round_trip(hass: HomeAssistant) -> None: """Test json_fragment roundtrip.""" integration = await loader.async_get_integration(hass, "hue") diff --git a/tests/test_setup.py b/tests/test_setup.py index bb221c7cb4c..96a13017430 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -57,21 +57,21 @@ async def test_validate_component_config(hass: HomeAssistant) -> None: with assert_setup_component(0): assert not await setup.async_setup_component(hass, "comp_conf", {}) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) with assert_setup_component(0): assert not await setup.async_setup_component( hass, "comp_conf", {"comp_conf": None} ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) with assert_setup_component(0): assert not await setup.async_setup_component( hass, "comp_conf", {"comp_conf": {}} ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) with assert_setup_component(0): assert not await setup.async_setup_component( @@ -80,7 +80,7 @@ async def test_validate_component_config(hass: HomeAssistant) -> None: {"comp_conf": {"hello": "world", "invalid": "extra"}}, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) with assert_setup_component(1): assert await setup.async_setup_component( @@ -111,7 +111,7 @@ async def test_validate_platform_config( {"platform_conf": {"platform": "not_existing", "hello": "world"}}, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("platform_conf") with assert_setup_component(1): @@ -121,7 +121,7 @@ async def test_validate_platform_config( {"platform_conf": {"platform": "whatever", "hello": "world"}}, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("platform_conf") with assert_setup_component(1): @@ -131,7 +131,7 @@ async def test_validate_platform_config( {"platform_conf": [{"platform": "whatever", "hello": "world"}]}, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("platform_conf") # Any falsey platform config will be ignored (None, {}, etc) @@ -240,7 +240,7 @@ async def test_validate_platform_config_4(hass: HomeAssistant) -> None: }, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("platform_conf") @@ -345,7 +345,7 @@ async def test_component_not_setup_missing_dependencies(hass: HomeAssistant) -> assert not await setup.async_setup_component(hass, "comp", {}) assert "comp" not in hass.config.components - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) mock_integration(hass, MockModule("comp2", dependencies=deps)) mock_integration(hass, MockModule("maybe_existing")) @@ -353,6 +353,76 @@ async def test_component_not_setup_missing_dependencies(hass: HomeAssistant) -> assert await setup.async_setup_component(hass, "comp2", {}) +async def test_component_not_setup_already_setup_dependencies( + hass: HomeAssistant, +) -> None: + """Test we do not set up component dependencies if they are already set up.""" + mock_integration( + hass, + MockModule( + "comp", + dependencies=["dep1"], + partial_manifest={"after_dependencies": ["dep2"]}, + ), + ) + mock_integration(hass, MockModule("dep1")) + mock_integration(hass, MockModule("dep2")) + + setup.async_set_domains_to_be_loaded(hass, {"comp", "dep2"}) + + hass.config.components.add("dep1") + hass.config.components.add("dep2") + + with patch( + "homeassistant.setup.async_setup_component", + side_effect=setup.async_setup_component, + ) as mock_setup: + await mock_setup(hass, "comp", {}) + + assert mock_setup.call_count == 1 + + +@pytest.mark.usefixtures("mock_handlers") +async def test_component_setup_dependencies_with_config_entry( + hass: HomeAssistant, +) -> None: + """Test we wait for a dependency with config entry.""" + calls: list[str] = [] + + async def mock_async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + await asyncio.sleep(0) + calls.append("entry") + return True + + mock_integration(hass, MockModule("comp", async_setup_entry=mock_async_setup_entry)) + mock_platform(hass, "comp.config_flow", None) + MockConfigEntry(domain="comp").add_to_hass(hass) + + async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + calls.append("comp") + return True + + mock_integration( + hass, + MockModule("comp2", dependencies=["comp"], async_setup=mock_async_setup), + ) + mock_integration( + hass, + MockModule("comp3", dependencies=["comp"], async_setup=mock_async_setup), + ) + + await asyncio.gather( + setup.async_setup_component(hass, "comp2", {}), + setup.async_setup_component(hass, "comp3", {}), + ) + + assert "comp" in hass.config.components + assert "comp2" in hass.config.components + assert "comp3" in hass.config.components + + assert calls == ["entry", "comp", "comp"] + + async def test_component_failing_setup(hass: HomeAssistant) -> None: """Test component that fails setup.""" mock_integration(hass, MockModule("comp", setup=lambda hass, config: False)) @@ -373,8 +443,8 @@ async def test_component_exception_setup(hass: HomeAssistant) -> None: mock_integration(hass, MockModule(domain, setup=exception_setup)) assert not await setup.async_setup_component(hass, domain, {}) - assert domain in hass.data[setup.DATA_SETUP] - assert domain not in hass.data[setup.DATA_SETUP_DONE] + assert domain in hass.data[setup._DATA_SETUP] + assert domain not in hass.data[setup._DATA_SETUP_DONE] assert domain not in hass.config.components @@ -393,8 +463,8 @@ async def test_component_base_exception_setup(hass: HomeAssistant) -> None: await setup.async_setup_component(hass, "comp", {}) assert str(exc_info.value) == "fail!" - assert domain in hass.data[setup.DATA_SETUP] - assert domain not in hass.data[setup.DATA_SETUP_DONE] + assert domain in hass.data[setup._DATA_SETUP] + assert domain not in hass.data[setup._DATA_SETUP_DONE] assert domain not in hass.config.components @@ -407,12 +477,12 @@ async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: domains = {domain_good, domain_bad, domain_exception, domain_base_exception} setup.async_set_domains_to_be_loaded(hass, domains) - assert set(hass.data[setup.DATA_SETUP_DONE]) == domains - setup_done = dict(hass.data[setup.DATA_SETUP_DONE]) + assert set(hass.data[setup._DATA_SETUP_DONE]) == domains + setup_done = dict(hass.data[setup._DATA_SETUP_DONE]) # Calling async_set_domains_to_be_loaded again should not create new futures setup.async_set_domains_to_be_loaded(hass, domains) - assert setup_done == hass.data[setup.DATA_SETUP_DONE] + assert setup_done == hass.data[setup._DATA_SETUP_DONE] def good_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Success.""" @@ -445,8 +515,8 @@ async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: await setup.async_setup_component(hass, domain_base_exception, {}) # Check the result of the setup - assert not hass.data[setup.DATA_SETUP_DONE] - assert set(hass.data[setup.DATA_SETUP]) == { + assert not hass.data[setup._DATA_SETUP_DONE] + assert set(hass.data[setup._DATA_SETUP]) == { domain_bad, domain_exception, domain_base_exception, @@ -455,7 +525,30 @@ async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: # Calling async_set_domains_to_be_loaded again should not create any new futures setup.async_set_domains_to_be_loaded(hass, domains) - assert not hass.data[setup.DATA_SETUP_DONE] + assert not hass.data[setup._DATA_SETUP_DONE] + + +async def test_component_setup_after_dependencies(hass: HomeAssistant) -> None: + """Test that after dependencies are set up before the component.""" + mock_integration(hass, MockModule("dep")) + mock_integration( + hass, MockModule("comp", partial_manifest={"after_dependencies": ["dep"]}) + ) + mock_integration( + hass, MockModule("comp2", partial_manifest={"after_dependencies": ["dep"]}) + ) + + setup.async_set_domains_to_be_loaded(hass, {"comp"}) + + assert await setup.async_setup_component(hass, "comp", {}) + assert "comp" in hass.config.components + assert "dep" not in hass.config.components + + setup.async_set_domains_to_be_loaded(hass, {"comp2", "dep"}) + + assert await setup.async_setup_component(hass, "comp2", {}) + assert "comp2" in hass.config.components + assert "dep" in hass.config.components async def test_component_setup_with_validation_and_dependency( @@ -515,7 +608,7 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: assert mock_setup.call_count == 0 assert len(mock_notify.mock_calls) == 1 - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("switch") with ( @@ -537,7 +630,7 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: assert mock_setup.call_count == 0 assert len(mock_notify.mock_calls) == 1 - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("switch") with ( @@ -563,7 +656,7 @@ async def test_disable_component_if_invalid_return(hass: HomeAssistant) -> None: assert not await setup.async_setup_component(hass, "disabled_component", {}) assert "disabled_component" not in hass.config.components - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) mock_integration( hass, MockModule("disabled_component", setup=lambda hass, config: False), @@ -572,7 +665,7 @@ async def test_disable_component_if_invalid_return(hass: HomeAssistant) -> None: assert not await setup.async_setup_component(hass, "disabled_component", {}) assert "disabled_component" not in hass.config.components - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) mock_integration( hass, MockModule("disabled_component", setup=lambda hass, config: True) ) @@ -846,7 +939,7 @@ async def test_integration_only_setup_entry(hass: HomeAssistant) -> None: async def test_async_start_setup_running(hass: HomeAssistant) -> None: """Test setup started context manager does nothing when running.""" assert hass.state is CoreState.running - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) with setup.async_start_setup( hass, integration="august", phase=setup.SetupPhases.SETUP @@ -859,7 +952,7 @@ async def test_async_start_setup_config_entry( ) -> None: """Test setup started keeps track of setup times with a config entry.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -969,7 +1062,7 @@ async def test_async_start_setup_config_entry_late_platform( ) -> None: """Test setup started tracks config entry time with a late platform load.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1023,7 +1116,7 @@ async def test_async_start_setup_config_entry_platform_wait( ) -> None: """Test setup started tracks wait time when a platform loads inside of config entry setup.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1065,7 +1158,7 @@ async def test_async_start_setup_config_entry_platform_wait( async def test_async_start_setup_top_level_yaml(hass: HomeAssistant) -> None: """Test setup started context manager keeps track of setup times with modern yaml.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1081,7 +1174,7 @@ async def test_async_start_setup_top_level_yaml(hass: HomeAssistant) -> None: async def test_async_start_setup_platform_integration(hass: HomeAssistant) -> None: """Test setup started keeps track of setup times a platform integration.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1115,7 +1208,7 @@ async def test_async_start_setup_legacy_platform_integration( ) -> None: """Test setup started keeps track of setup times for a legacy platform integration.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1237,7 +1330,7 @@ async def test_setup_config_entry_from_yaml( assert await setup.async_setup_component(hass, "test_integration_only_entry", {}) assert expected_warning not in caplog.text caplog.clear() - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("test_integration_only_entry") # There should be a warning, but setup should not fail @@ -1246,7 +1339,7 @@ async def test_setup_config_entry_from_yaml( ) assert expected_warning in caplog.text caplog.clear() - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("test_integration_only_entry") # There should be a warning, but setup should not fail @@ -1255,7 +1348,7 @@ async def test_setup_config_entry_from_yaml( ) assert expected_warning in caplog.text caplog.clear() - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("test_integration_only_entry") # There should be a warning, but setup should not fail @@ -1266,7 +1359,7 @@ async def test_setup_config_entry_from_yaml( ) assert expected_warning in caplog.text caplog.clear() - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("test_integration_only_entry") @@ -1315,3 +1408,42 @@ async def test_async_prepare_setup_platform( await setup.async_prepare_setup_platform(hass, {}, "button", "test") is None ) assert button_platform is not None + + +async def test_async_wait_component(hass: HomeAssistant) -> None: + """Test async_wait_component.""" + setup_stall = asyncio.Event() + setup_started = asyncio.Event() + + async def mock_setup(hass: HomeAssistant, _) -> bool: + setup_started.set() + await setup_stall.wait() + return True + + mock_integration(hass, MockModule("test", async_setup=mock_setup)) + + # The integration not loaded, and is also not scheduled to load + assert await setup.async_wait_component(hass, "test") is False + + # Mark the component as scheduled to be loaded + setup.async_set_domains_to_be_loaded(hass, {"test"}) + + # Start loading the component, including its config entries + hass.async_create_task(setup.async_setup_component(hass, "test", {})) + await setup_started.wait() + + # The component is not yet loaded + assert "test" not in hass.config.components + + # Allow setup to proceed + setup_stall.set() + + # The component is scheduled to load, this will block until the config entry is loaded + assert await setup.async_wait_component(hass, "test") is True + + # The component has been loaded + assert "test" in hass.config.components + + # Clear the event, then call again to make sure we don't block + setup_stall.clear() + assert await setup.async_wait_component(hass, "test") is True diff --git a/tests/test_test_fixtures.py b/tests/test_test_fixtures.py index 0b8fd20a7c0..0bada601a3b 100644 --- a/tests/test_test_fixtures.py +++ b/tests/test_test_fixtures.py @@ -9,9 +9,9 @@ from aiohttp import web import pytest import pytest_socket -from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, async_get_hass from homeassistant.helpers import translation +from homeassistant.helpers.http import HomeAssistantView from homeassistant.setup import async_setup_component from .common import MockModule, mock_integration diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 633f98dc5b3..9207ba0904b 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -110,6 +110,10 @@ class AiohttpClientMocker: """Register a mock patch request.""" self.request("patch", *args, **kwargs) + def head(self, *args, **kwargs): + """Register a mock head request.""" + self.request("head", *args, **kwargs) + @property def call_count(self): """Return the number of requests made.""" diff --git a/tests/testing_config/blueprints/template/test_event_sensor.yaml b/tests/testing_config/blueprints/template/test_event_sensor.yaml index 8b615eb90ba..2ce8519c8e9 100644 --- a/tests/testing_config/blueprints/template/test_event_sensor.yaml +++ b/tests/testing_config/blueprints/template/test_event_sensor.yaml @@ -14,7 +14,7 @@ blueprint: description: The event_data for the event trigger selector: object: -trigger: +triggers: - trigger: event event_type: !input event_type event_data: !input event_data diff --git a/tests/testing_config/blueprints/template/test_event_sensor_legacy_schema.yaml b/tests/testing_config/blueprints/template/test_event_sensor_legacy_schema.yaml new file mode 100644 index 00000000000..8b615eb90ba --- /dev/null +++ b/tests/testing_config/blueprints/template/test_event_sensor_legacy_schema.yaml @@ -0,0 +1,27 @@ +blueprint: + name: Create Sensor from Event + description: Creates a timestamp sensor from an event + domain: template + source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/template/blueprints/event_sensor.yaml + input: + event_type: + name: Name of the event_type + description: The event_type for the event trigger + selector: + text: + event_data: + name: The data for the event + description: The event_data for the event trigger + selector: + object: +trigger: + - trigger: event + event_type: !input event_type + event_data: !input event_data +variables: + event_data: "{{ trigger.event.data }}" +sensor: + state: "{{ now() }}" + device_class: timestamp + attributes: + data: "{{ event_data }}" diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 96ba8d0a325..3f288962009 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -298,6 +298,10 @@ def test_parse_time_expression() -> None: assert list(range(0, 60, 5)) == dt_util.parse_time_expression("/5", 0, 59) + assert dt_util.parse_time_expression("/4", 5, 20) == [8, 12, 16, 20] + assert dt_util.parse_time_expression("/10", 10, 30) == [10, 20, 30] + assert dt_util.parse_time_expression("/3", 4, 29) == [6, 9, 12, 15, 18, 21, 24, 27] + assert dt_util.parse_time_expression([2, 1, 3], 0, 59) == [1, 2, 3] assert list(range(24)) == dt_util.parse_time_expression("*", 0, 23) diff --git a/tests/util/test_ssl.py b/tests/util/test_ssl.py index c0cd2fdba10..0cef48e0d84 100644 --- a/tests/util/test_ssl.py +++ b/tests/util/test_ssl.py @@ -7,6 +7,7 @@ import pytest from homeassistant.util.ssl import ( SSLCipherList, client_context, + create_client_context, create_no_verify_ssl_context, ) @@ -56,3 +57,28 @@ def test_ssl_context_caching() -> None: assert create_no_verify_ssl_context() is create_no_verify_ssl_context( SSLCipherList.PYTHON_DEFAULT ) + + +def test_create_client_context(mock_sslcontext) -> None: + """Test create client context.""" + with patch("homeassistant.util.ssl.ssl.SSLContext", return_value=mock_sslcontext): + client_context() + mock_sslcontext.set_ciphers.assert_not_called() + + client_context(SSLCipherList.MODERN) + mock_sslcontext.set_ciphers.assert_not_called() + + client_context(SSLCipherList.INTERMEDIATE) + mock_sslcontext.set_ciphers.assert_not_called() + + client_context(SSLCipherList.INSECURE) + mock_sslcontext.set_ciphers.assert_not_called() + + +def test_create_client_context_independent() -> None: + """Test create_client_context independence.""" + shared_context = client_context() + independent_context_1 = create_client_context() + independent_context_2 = create_client_context() + assert shared_context is not independent_context_1 + assert independent_context_1 is not independent_context_2 diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 3f55ceef242..883b17c733c 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -806,12 +806,30 @@ _CONVERTED_VALUE: dict[ 2500, UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND, ), + ( + 1, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_SECOND, + 3600, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + ), + ( + 1, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_SECOND, + 3600000, + UnitOfVolumeFlowRate.LITERS_PER_HOUR, + ), ( 3, UnitOfVolumeFlowRate.LITERS_PER_MINUTE, 50, UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND, ), + ( + 3.6, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + 1, + UnitOfVolumeFlowRate.LITERS_PER_SECOND, + ), ], }